<?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: uehara</title>
    <description>The latest articles on DEV Community by uehara (@uehara).</description>
    <link>https://dev.to/uehara</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%2F75671%2F44efc942-ce5e-4c29-a5c0-58a7a90fc5fe.png</url>
      <title>DEV Community: uehara</title>
      <link>https://dev.to/uehara</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/uehara"/>
    <language>en</language>
    <item>
      <title>Giving CI your own login? Pull secrets into GitHub Actions with one service token</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Fri, 06 Nov 2026 01:01:03 +0000</pubDate>
      <link>https://dev.to/uehara/giving-ci-your-own-login-pull-secrets-into-github-actions-with-one-service-token-30dm</link>
      <guid>https://dev.to/uehara/giving-ci-your-own-login-pull-secrets-into-github-actions-with-one-service-token-30dm</guid>
      <description>&lt;p&gt;This is part 7 of the series "dotvault, Start to Finish: One Command from Your Laptop to CI, Your Team, and Your AI Agents." dotvault will be released free of charge when this series ends.&lt;/p&gt;

&lt;p&gt;GitHub Secrets can end up holding every line of your &lt;code&gt;.env&lt;/code&gt;, or a personal login that lets CI act as you. Neither is needed. With dotvault, GitHub holds exactly one secret, a service token, and the workflow pulls the values at run time with &lt;code&gt;dotvault pull demo-app --env production&lt;/code&gt;. In my test, the Actions log showed 5 keys written to &lt;code&gt;.env&lt;/code&gt; and the build reporting &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt;, and &lt;code&gt;API_BASE_URL&lt;/code&gt; as set. When I revoked the token in the console and re-ran the same workflow, it stopped at the pull step with &lt;code&gt;AUTH_REQUIRED&lt;/code&gt; and exit code 3, before the build ran.&lt;/p&gt;

&lt;p&gt;This is part 7 of 12 in a series on dotvault, which keeps &lt;code&gt;.env&lt;/code&gt; secrets for a team and hands them to people, CI and AI agents. Everything below was run against the production console and CLI 0.15.7, with GitHub Actions runs in a private repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service tokens are issued in the console only.&lt;/strong&gt; Service Tokens → Create token, then Name, Role, Project and Expiry. &lt;code&gt;dotvault token create&lt;/code&gt; in the CLI just points you to the console. For CI, pick Read only, the one project, and an expiry (the default is Never).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI holds one secret: &lt;code&gt;DOTVAULT_TOKEN&lt;/code&gt;.&lt;/strong&gt; Copy the &lt;code&gt;est_…&lt;/code&gt; value shown once after Create into GitHub Secrets, and run &lt;code&gt;dotvault pull demo-app --env production&lt;/code&gt; in the workflow. Naming the project means the repository does not need a &lt;code&gt;project-config.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the token reaches.&lt;/strong&gt; Read only blocks writes, and a project name other than &lt;code&gt;demo-app&lt;/code&gt; is refused (tested with a name that does not exist). The org's Global Keys pool is off limits too. Tokens are scoped by project, so the same token can pull that project's development and staging as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoke stops the next run.&lt;/strong&gt; &lt;code&gt;pull&lt;/code&gt; and &lt;code&gt;run&lt;/code&gt; fail with &lt;code&gt;✖ AUTH_REQUIRED: invalid service token&lt;/code&gt; (exit 3); the raw API returns HTTP 401. &lt;code&gt;dotvault whoami&lt;/code&gt; only reads the token it has, so check a token with &lt;code&gt;pull&lt;/code&gt;, not &lt;code&gt;whoami&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you read on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where we are.&lt;/strong&gt; This continues with the &lt;code&gt;demo-app&lt;/code&gt; project from part 2. In part 6, two Global Keys were bound to it (&lt;code&gt;CF_API_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN_WEB&lt;/code&gt;). Before these captures I set &lt;code&gt;DATABASE_URL&lt;/code&gt; and &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; (dummy values) in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setup.&lt;/strong&gt; The production app.dotvault.io and CLI 0.15.7. The token runs used a Node 20 Linux container with no login and no OS keychain, only &lt;code&gt;DOTVAULT_TOKEN&lt;/code&gt; in the environment, the same conditions as CI. GitHub Actions ran in a private repository made for this part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal and Actions logs are rebuilt.&lt;/strong&gt; The text is the raw output (Actions logs via &lt;code&gt;gh run view --log&lt;/code&gt;), laid out for reading. Timestamps and the &lt;code&gt;npm install&lt;/code&gt; warnings are dropped and shown as a single "…" line. Exit codes recorded as [exit=N] in the raw output appear as &lt;code&gt;echo $?&lt;/code&gt; lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokens are masked&lt;/strong&gt; as &lt;code&gt;est_••••&lt;/code&gt; in screenshots and terminals. Both tokens created for this part were revoked afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo account.&lt;/strong&gt; Project names, values and the org id are demo data made for this series. &lt;code&gt;dummy-…&lt;/code&gt; and &lt;code&gt;cf_demo_…&lt;/code&gt; are placeholders that work nowhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terms.&lt;/strong&gt; A service token is the credential CI or a server uses instead of a person's login (it starts with &lt;code&gt;est_&lt;/code&gt;). Revoke disables it. A step is one stage of a workflow. An exit code is the number a command returns when it finishes: 0 is success.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. The Service Tokens page and Create token
&lt;/h2&gt;

&lt;p&gt;With no tokens yet, Service Tokens in the sidebar looks like this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftipfyl0wx6sk17ynhp6x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftipfyl0wx6sk17ynhp6x.png" alt="Service Tokens with no tokens: the notice &amp;quot;Non-interactive IDs for CI / Lambda / SDKs. The secret is shown only once, at issuance. If one leaks, revoke it immediately. Everything is recorded in Audit.&amp;quot;, the Create token button, &amp;quot;Use a token by exporting DOTVAULT_TOKEN=est_… for the CLI/SDK.&amp;quot;, and &amp;quot;No service tokens yet.&amp;quot;" width="800" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create token opens a dialog with four fields: Name, Role, Project, Expiry. The defaults are Read only, All projects (whole org), and Never.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzrt2tptiptgo9jefjxt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzrt2tptiptgo9jefjxt.png" alt="The Create service token dialog with its defaults: Name empty with ci-deploy as placeholder, Role Read only (viewer), Project All projects (whole org), Expiry Never." width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are three roles. The name in parentheses is dotvault's internal role name.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label in the console&lt;/th&gt;
&lt;th&gt;Description in the console&lt;/th&gt;
&lt;th&gt;For CI?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read only (viewer)&lt;/td&gt;
&lt;td&gt;Can pull secrets only. Minimal privilege for reading in CI.&lt;/td&gt;
&lt;td&gt;Yes (this part)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read/write (member)&lt;/td&gt;
&lt;td&gt;In addition to pull, can add, update, and delete keys.&lt;/td&gt;
&lt;td&gt;Only if CI writes values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin / automation (provisioner)&lt;/td&gt;
&lt;td&gt;Can also perform admin operations such as creating projects and changing settings. Intended for Claude Code headless onboarding.&lt;/td&gt;
&lt;td&gt;No (part 8)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Choosing Admin / automation adds a warning about the impact of a leak and a note that its scope is the whole org whatever project you pick.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rx4grugcu8zzzdbfrah.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rx4grugcu8zzzdbfrah.png" alt="The dialog with Admin / automation (provisioner): the warning &amp;quot;provisioner is a strong credential … Because the impact of a leak is large, be careful who it is issued to and how it is stored.&amp;quot; and, under Project, &amp;quot;provisioner only performs admin operations such as creating projects, so its scope is the whole org regardless of this choice&amp;quot;." width="800" height="898"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expiry offers Never, 30 days, 90 days and 1 year. Leaving the default creates a token that never expires. The console's own Help page says to keep the TTL short, so pick one for CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Creating the token
&lt;/h2&gt;

&lt;p&gt;For CI: Name &lt;code&gt;ci-deploy&lt;/code&gt;, Role Read only, Project &lt;code&gt;demo-app&lt;/code&gt;, Expiry 90 days.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyedmlfzyt2rrs4ztgxz3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyedmlfzyt2rrs4ztgxz3.png" alt="The dialog just before creating ci-deploy: Name ci-deploy, Role Read only (viewer), Project demo-app, Expiry 90 days." width="800" height="718"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create shows the &lt;code&gt;est_…&lt;/code&gt; token once. Copy it and put it in GitHub Secrets right away (section 5). Once this dialog closes, the token cannot be shown again. The image below is the same dialog for a second token (&lt;code&gt;ci-nightly&lt;/code&gt;), with the token text masked before the capture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6xn7bozor9wib90woj1b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6xn7bozor9wib90woj1b.png" alt="Token created, token masked: &amp;quot;Copy this token now — it is shown only once and cannot be retrieved again.&amp;quot;, est_••••, Copy and Done." width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After Done, the list has a new row. For ci-deploy, Expires is 90 days after Created.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vx2u3e1syi7k5o1ns2m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vx2u3e1syi7k5o1ns2m.png" alt="Service Tokens with two active rows: ci-nightly (Read only (viewer), demo-app, expiring 30 days after creation) and ci-deploy (Read only (viewer), demo-app, expiring 90 days after creation), each with Revoke." width="799" height="257"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea itself (shown once, revoke on a leak) was introduced in &lt;a href="https://dev.to/uehara/distributing-secrets-safely-we-built-dotvault-which-delivers-env-files-with-one-command-2a0k"&gt;an earlier post about building dotvault&lt;/a&gt;. This part runs it end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The CLI cannot issue tokens
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ndeh96mbl3s4h38oelt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ndeh96mbl3s4h38oelt.png" alt="dotvault token create --name ci-deploy (rebuilt): &amp;quot; raw `dotvault token create` endraw  is no longer available in the CLI.&amp;quot;, &amp;quot;Service tokens are created, listed, and revoked in the dotvault admin console:&amp;quot;, &amp;quot;https://app.dotvault.io/admin/tokens&amp;quot;, &amp;quot;The token API only accepts admin-console sessions, so a CLI login cannot manage tokens.&amp;quot;, exit code 1." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;token list&lt;/code&gt; prints the same message, with &lt;code&gt;token list&lt;/code&gt; as the command name in the first line, and exits 1. Creating, listing and revoking all happen on the console's Service Tokens page.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Pulling with nothing but the token
&lt;/h2&gt;

&lt;p&gt;Before involving GitHub, I checked the token on a terminal with no login, only &lt;code&gt;DOTVAULT_TOKEN&lt;/code&gt; set and &lt;code&gt;demo-app&lt;/code&gt;'s &lt;code&gt;project-config.json&lt;/code&gt; in the directory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y6p9ukuzauv3bfb204x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y6p9ukuzauv3bfb204x.png" alt="Token-only terminal (rebuilt): whoami prints &amp;quot;Service token (DOTVAULT_TOKEN=est_••••••••…) — CI / Lambda mode&amp;quot;; pull -E production prints &amp;quot;✓ Wrote 5 keys to /work/ci-run/.env (project=demo-app, env=production, file=.env).&amp;quot; and &amp;quot;✔ 1 file synced, 1 excluded, 5 keys total&amp;quot;; cat .env shows CF_API_TOKEN, SENTRY_DSN_WEB, API_BASE_URL, DATABASE_URL and STRIPE_SECRET_KEY." width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;whoami&lt;/code&gt; reports a service token instead of an email address. Of the 5 keys, &lt;code&gt;DATABASE_URL&lt;/code&gt; and &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; are production keys of the project, &lt;code&gt;API_BASE_URL&lt;/code&gt; comes from its Common (_shared) layer, and the first two are the Global Keys bound in part 6. "1 excluded" is &lt;code&gt;.env.local&lt;/code&gt;, which is declared in &lt;code&gt;project-config.json&lt;/code&gt; but has no production values. &lt;code&gt;dotvault run -E production -- &amp;lt;command&amp;gt;&lt;/code&gt; worked with the same token too, passing values to the command without writing a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Handing it to GitHub Actions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The secret
&lt;/h3&gt;

&lt;p&gt;In the repository: Settings → Secrets and variables → Actions → New repository secret, named &lt;code&gt;DOTVAULT_TOKEN&lt;/code&gt;, with the token as the value. I did the same with the GitHub CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh secret &lt;span class="nb"&gt;set &lt;/span&gt;DOTVAULT_TOKEN &lt;span class="nt"&gt;-R&lt;/span&gt; &amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;
&lt;span class="c"&gt;# paste the token when prompted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the only secret GitHub holds. &lt;code&gt;DATABASE_URL&lt;/code&gt; and the rest stay in dotvault.&lt;/p&gt;

&lt;h3&gt;
  
  
  The workflow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pull dotvault secrets&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DOTVAULT_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOTVAULT_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npm install -g @earthlink/dotvault&lt;/span&gt;
          &lt;span class="s"&gt;dotvault whoami&lt;/span&gt;
          &lt;span class="s"&gt;dotvault pull demo-app --env production&lt;/span&gt;
          &lt;span class="s"&gt;cut -d= -f1 .env&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Your build step&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming the project in &lt;code&gt;dotvault pull demo-app --env production&lt;/code&gt; means no &lt;code&gt;project-config.json&lt;/code&gt; in the repository. &lt;code&gt;cut -d= -f1 .env&lt;/code&gt; prints the key names and dotvault's marker lines (&lt;code&gt;# BEGIN&lt;/code&gt; and &lt;code&gt;# END&lt;/code&gt;) only, so no values reach the log. The &lt;code&gt;npm run build&lt;/code&gt; here is a small stand-in that reads &lt;code&gt;.env&lt;/code&gt; and prints set or MISSING for &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; and &lt;code&gt;API_BASE_URL&lt;/code&gt;, never the values. &lt;code&gt;pull&lt;/code&gt; only writes &lt;code&gt;.env&lt;/code&gt;; it does not turn the values into environment variables for the next step. This build reads &lt;code&gt;.env&lt;/code&gt; itself, so the values arrived. A build that does not read &lt;code&gt;.env&lt;/code&gt; gets nothing, so give such a build a line that checks the keys it needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The run
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco5gg9w2eek2vz92zt2o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco5gg9w2eek2vz92zt2o.png" alt="GitHub Actions log (rebuilt). Pull dotvault secrets: whoami &amp;quot;Service token (DOTVAULT_TOKEN=est_••••••••…) — CI / Lambda mode&amp;quot;, pull &amp;quot;✓ Wrote 5 keys to /home/runner/work/dotvault-ci-demo/dotvault-ci-demo/.env (project=demo-app, env=production, file=.env).&amp;quot; and &amp;quot;✔ 1 file synced, 0 excluded, 5 keys total&amp;quot;, the marker lines and five key names. Your build step: DATABASE_URL: set, STRIPE_SECRET_KEY: set, API_BASE_URL: set, build finished." width="800" height="583"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DOTVAULT_TOKEN: ***&lt;/code&gt; is GitHub masking the secret. The first 8 characters after &lt;code&gt;est_&lt;/code&gt; printed by &lt;code&gt;whoami&lt;/code&gt; are not masked by GitHub and stay in the log (masked in the image). It says 0 excluded here because, with no &lt;code&gt;project-config.json&lt;/code&gt;, only &lt;code&gt;.env&lt;/code&gt; is written.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What this token reaches, and what it does not
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f5gzo7g6x9yf0u3wk4l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f5gzo7g6x9yf0u3wk4l.png" alt="What the token reaches (rebuilt): set FOO=1 -E production fails with &amp;quot;✖ PERMISSION_DENIED: … (server: service token is read-only (viewer); write requires a member token).&amp;quot; and 4; pull nonexistent-proj -E production fails with &amp;quot;(server: service token is not scoped to this project)&amp;quot; and 4; global status fails with &amp;quot;(server: no access to the org global key pool)&amp;quot;; pull -E development succeeds with 8 keys total and pull -E staging with 5 keys total." width="799" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action with &lt;code&gt;ci-deploy&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;set FOO=1 -E production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PERMISSION_DENIED (service token is read-only (viewer); write requires a member token)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete STRIPE_SECRET_KEY -E production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same PERMISSION_DENIED&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pull nonexistent-proj -E production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PERMISSION_DENIED (service token is not scoped to this project)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;global status&lt;/code&gt; (the Global Keys pool)&lt;/td&gt;
&lt;td&gt;PERMISSION_DENIED (no access to the org global key pool)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pull -E production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5 keys&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pull -E development&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8 keys (&lt;code&gt;.env&lt;/code&gt; 7 + &lt;code&gt;.env.local&lt;/code&gt; 1)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pull -E staging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5 keys&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Writes and other projects are refused. This org has no project besides &lt;code&gt;demo-app&lt;/code&gt;, so "other project" was tested with a name that does not exist; the server's reason was that the token is not scoped to it. The Global Keys pool is not visible to this token either.&lt;/p&gt;

&lt;p&gt;Tokens are scoped by project. The same token also pulled development (8 keys) and staging (5 keys). Issue a CI token for a project whose values that CI is allowed to receive.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Without the CLI: curl
&lt;/h2&gt;

&lt;p&gt;Where the CLI cannot be installed, call the API with the token in the HTTP &lt;code&gt;Authorization&lt;/code&gt; header as &lt;code&gt;Bearer &amp;lt;token&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F66llhcgzb5qre7l7ouz6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F66llhcgzb5qre7l7ouz6.png" alt="curl (rebuilt): GET https://api.dotvault.io/v1/env/pull?project=demo-app&amp;amp;env=production&amp;amp;file=.env with Authorization: Bearer $DOTVAULT_TOKEN returns JSON with project, env, file, keys (five keys with values) and source (shared: API_BASE_URL; envSpecific: DATABASE_URL, STRIPE_SECRET_KEY; global: CF_API_TOKEN, SENTRY_DSN_WEB). Without a token: {&amp;quot;error&amp;quot;:&amp;quot;UNAUTHORIZED&amp;quot;,&amp;quot;message&amp;quot;:&amp;quot;Missing token&amp;quot;} and HTTP 401." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;source&lt;/code&gt; says where each key came from, and &lt;code&gt;global&lt;/code&gt; lists &lt;code&gt;CF_API_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN_WEB&lt;/code&gt;, confirming from the API side that the first two keys in section 4 are the Global Keys bound in part 6. Without &lt;code&gt;file&lt;/code&gt;, the same object comes back inside a &lt;code&gt;files&lt;/code&gt; array. Success is HTTP 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Revoking
&lt;/h2&gt;

&lt;p&gt;Revoke on a row opens the browser's own confirmation, so there is no screenshot:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Revoke token “ci-deploy”? CI jobs and services using it will lose access immediately.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After OK, Status becomes revoked. The list filter has Active only (the default), All (incl. revoked) and Revoked only.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flseg79a8po7x48jgfjwb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flseg79a8po7x48jgfjwb.png" alt="Service Tokens filtered to Revoked only: one row, ci-deploy (Read only (viewer), demo-app, revoked), with no Revoke button." width="799" height="229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Right after revoking, I re-ran the run from section 5 (&lt;code&gt;gh run rerun&lt;/code&gt;, the same as Re-run jobs in GitHub).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2pk0bkgsculvpyym7ji.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2pk0bkgsculvpyym7ji.png" alt="GitHub Actions re-run log (rebuilt, after Revoke). Pull dotvault secrets is marked failed: whoami still prints &amp;quot;Service token (DOTVAULT_TOKEN=est_••••••••…) — CI / Lambda mode&amp;quot;, pull prints &amp;quot;✖ AUTH_REQUIRED: invalid service token&amp;quot;, then &amp;quot;Error: Process completed with exit code 3.&amp;quot; Your build step does not run." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The job stopped at the pull with exit code 3, and the build step never ran. The terminal agrees:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcalqksmyxd72agw160o9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcalqksmyxd72agw160o9.png" alt="After Revoke (rebuilt): dotvault whoami still prints &amp;quot;Service token (…) — CI / Lambda mode&amp;quot; with 0; pull -E production and run -E production both fail with &amp;quot;✖ AUTH_REQUIRED: invalid service token&amp;quot; and 3; curl returns {&amp;quot;error&amp;quot;:&amp;quot;UNAUTHORIZED&amp;quot;,&amp;quot;message&amp;quot;:&amp;quot;invalid service token&amp;quot;} and HTTP 401." width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;whoami&lt;/code&gt; still reports "CI / Lambda mode" and exits 0 after revocation, because it only reads the token it has and does not ask the server. Check whether a token still works with &lt;code&gt;pull&lt;/code&gt;. The error after revocation is &lt;code&gt;AUTH_REQUIRED&lt;/code&gt;, not &lt;code&gt;PERMISSION_DENIED&lt;/code&gt;; a made-up &lt;code&gt;est_&lt;/code&gt; value gave the same &lt;code&gt;AUTH_REQUIRED: invalid service token&lt;/code&gt; (exit 3), so when a CI failure shows it, suspect a revoked token or a wrong secret value. With the secret empty (a misspelled secret name, for example), the error was "AUTH_REQUIRED: Not logged in. Run &lt;code&gt;dotvault login&lt;/code&gt; first." (exit 3).&lt;/p&gt;

&lt;h2&gt;
  
  
  9. When it fails
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dotvault token create&lt;/code&gt; / &lt;code&gt;token list&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dotvault token create&lt;/code&gt; / &lt;code&gt;dotvault token list&lt;/code&gt; is no longer available in the CLI. … points to the console&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;set&lt;/code&gt; / &lt;code&gt;delete&lt;/code&gt; with a Read only token&lt;/td&gt;
&lt;td&gt;PERMISSION_DENIED (read-only (viewer); write requires a member token)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pull&lt;/code&gt; of a name other than &lt;code&gt;demo-app&lt;/code&gt; (tested with a nonexistent name)&lt;/td&gt;
&lt;td&gt;PERMISSION_DENIED (not scoped to this project)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pull&lt;/code&gt; / &lt;code&gt;run&lt;/code&gt; after Revoke, or with a made-up &lt;code&gt;est_&lt;/code&gt; value&lt;/td&gt;
&lt;td&gt;AUTH_REQUIRED: invalid service token&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pull&lt;/code&gt; with &lt;code&gt;DOTVAULT_TOKEN&lt;/code&gt; empty (e.g. a misspelled secret name)&lt;/td&gt;
&lt;td&gt;AUTH_REQUIRED: Not logged in. Run &lt;code&gt;dotvault login&lt;/code&gt; first.&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;whoami&lt;/code&gt; after Revoke&lt;/td&gt;
&lt;td&gt;Service token (…) — CI / Lambda mode (it only reads the local token)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API call with no token&lt;/td&gt;
&lt;td&gt;{"error":"UNAUTHORIZED","message":"Missing token"}&lt;/td&gt;
&lt;td&gt;HTTP 401&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;pull&lt;/code&gt; only writes &lt;code&gt;.env&lt;/code&gt;, so a build that does not read &lt;code&gt;.env&lt;/code&gt;, or that looks for a misspelled key name, can carry on without its values. One line in the job that shows which keys arrived catches that early (the &lt;code&gt;cut -d= -f1 .env&lt;/code&gt; in section 5, or a set / MISSING check like this part's build).&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 8 hands tokens to an AI agent. It issues two new tokens, a Read/write token that can write values and the Admin / automation token whose warning appeared in section 1, hands them to an AI agent, and goes from creating a project to pulling it with no human login.&lt;/p&gt;




&lt;p&gt;dotvault stores the secrets in your .env and config files server-side and hands them to any authorised developer, CI job or AI agent with a single command (&lt;a href="https://dotvault.io" rel="noopener noreferrer"&gt;https://dotvault.io&lt;/a&gt;). It will be released free of charge when this series ends, and the steps in these articles are the steps you will use on day one.&lt;/p&gt;

&lt;p&gt;If you find this useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotvault</category>
      <category>secrets</category>
      <category>githubactions</category>
      <category>devops</category>
    </item>
    <item>
      <title>Pasting the same Sentry DSN into every project? Put it in Global Keys once and bind it</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:59:39 +0000</pubDate>
      <link>https://dev.to/uehara/pasting-the-same-sentry-dsn-into-every-project-put-it-in-global-keys-once-and-bind-it-32n1</link>
      <guid>https://dev.to/uehara/pasting-the-same-sentry-dsn-into-every-project-put-it-in-global-keys-once-and-bind-it-32n1</guid>
      <description>&lt;p&gt;This is part 6 of the series "dotvault, Start to Finish: One Command from Your Laptop to CI, Your Team, and Your AI Agents." dotvault will be released free of charge when this series ends.&lt;/p&gt;

&lt;p&gt;A key you put in dotvault's Global Keys pool reaches no project until a project asks for it. The pool is org-wide and belongs to no project. A project receives a key from it only after it binds that key by name, from its own Global keys tab or with &lt;code&gt;dotvault global use &amp;lt;KEY&amp;gt;&lt;/code&gt;. Bound keys arrive through the ordinary &lt;code&gt;pull&lt;/code&gt; and &lt;code&gt;run&lt;/code&gt; at the lowest precedence. In my test, a value the project set for the same name in development replaced the pool's value in the pulled &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is part 6 of 12. Screens are from the production console and CLI 0.15.7, continuing with the &lt;code&gt;demo-app&lt;/code&gt; project from parts 2 to 5. Two results worth knowing before you start: &lt;code&gt;pull -E production&lt;/code&gt; succeeds on an environment where the project has no production keys of its own, and writes 3 keys (the two bound pool keys with their production overrides, plus &lt;code&gt;API_BASE_URL&lt;/code&gt; from the project's Common (_shared) layer); and &lt;code&gt;global describe&lt;/code&gt; on a name that is not in the pool, like &lt;code&gt;global drop&lt;/code&gt; on a key that was never bound, prints a success line and exits 0, so check names with &lt;code&gt;global keys&lt;/code&gt; and &lt;code&gt;global status&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A credential shared across the org goes in Global Keys, not in a project.&lt;/strong&gt; Use the Global Keys entry in the sidebar or &lt;code&gt;dotvault global set&lt;/code&gt;. The value you put there is the common value (Common in the console, &lt;code&gt;_all&lt;/code&gt; in the CLI) and applies to every environment. You can add a per-environment override on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A project receives only the keys it binds.&lt;/strong&gt; Nothing is broadcast. Bind from the project's Global keys tab, or run &lt;code&gt;dotvault global use &amp;lt;KEY&amp;gt;&lt;/code&gt; in the project directory. The name the project receives can differ from the pool name (&lt;code&gt;--as&lt;/code&gt; in the CLI, alias in the console).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receiving is the usual &lt;code&gt;pull&lt;/code&gt; and &lt;code&gt;run&lt;/code&gt;.&lt;/strong&gt; No extra flag. Bound keys are written first inside the managed block in &lt;code&gt;.env&lt;/code&gt; (that is only their position; they still lose to a project key of the same name). Pulling production gives you the production override. Because &lt;code&gt;pull&lt;/code&gt; rewrites &lt;code&gt;.env&lt;/code&gt; where you run it, check production from a separate directory. An environment with no production keys of its own still pulls: it gets the bound keys' production overrides plus the project's Common (_shared) values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The project's own value wins when names collide.&lt;/strong&gt; The pool is the weakest layer, common value and overrides alike. &lt;code&gt;global drop&lt;/code&gt; (the Unbind button on the project's Global keys tab) stops receiving a key. From the CLI, &lt;code&gt;global delete KEY&lt;/code&gt; removes the common value and &lt;code&gt;global delete KEY -E &amp;lt;env&amp;gt;&lt;/code&gt; removes that environment's override; removing the whole key, overrides included, is the console's Delete key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool commands need &lt;code&gt;--org &amp;lt;id&amp;gt;&lt;/code&gt;.&lt;/strong&gt; Without it, &lt;code&gt;global keys&lt;/code&gt; and &lt;code&gt;global set&lt;/code&gt; stopped with &lt;code&gt;ORG_CONTEXT_REQUIRED&lt;/code&gt; (exit 1). Even when you belong to only one org, the &lt;code&gt;global&lt;/code&gt; commands that work on the pool do not pick the org for you. Run &lt;code&gt;describe&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; with &lt;code&gt;--org&lt;/code&gt; too. &lt;code&gt;global use&lt;/code&gt;, &lt;code&gt;global status&lt;/code&gt; and &lt;code&gt;global drop&lt;/code&gt;, run inside the project directory, work without &lt;code&gt;--org&lt;/code&gt; (all exit 0). The id is in &lt;code&gt;dotvault whoami&lt;/code&gt;. A plain &lt;code&gt;dotvault set&lt;/code&gt; without &lt;code&gt;global&lt;/code&gt; is the exception: in a directory with no &lt;code&gt;project-config.json&lt;/code&gt;, it writes to the pool's common value with no &lt;code&gt;--org&lt;/code&gt; and exits 0. Run &lt;code&gt;set&lt;/code&gt; for project keys from inside the project directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binding state lives on the Global keys tab and in &lt;code&gt;global status&lt;/code&gt;.&lt;/strong&gt; A project's Keys tab lists the keys set in that project; bound pool keys show up in the BOUND AS column of its Global keys tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you read on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where we start.&lt;/strong&gt; Part 5 left &lt;code&gt;demo-app&lt;/code&gt; with &lt;code&gt;.env&lt;/code&gt; (5 keys) and &lt;code&gt;.env.local&lt;/code&gt; (1 key) in development. The &lt;code&gt;.npmrc&lt;/code&gt; and &lt;code&gt;.kube/config&lt;/code&gt; pushed in part 5 are still in development too; pushed files are not keys, so they are not in this part's key counts. You can follow this part without having read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment.&lt;/strong&gt; Production console, CLI 0.15.7, the same Node 20 Linux container as part 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The org is Personal.&lt;/strong&gt; The one-person org from part 2. It has no permission settings; everything, Global Keys included, is allowed from the start. How the pool looks in an org with invited members is part 9.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal screens are recomposed.&lt;/strong&gt; The output is reproduced verbatim in a cleaner frame. The raw output is kept as is. Where commands were run separately, they are sometimes joined into one line with &lt;code&gt;cd … &amp;amp;&amp;amp;&lt;/code&gt;, and exit codes, which the raw output records as [exit=N], are shown as &lt;code&gt;echo $?&lt;/code&gt; lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo account.&lt;/strong&gt; Project names, values and the org id were created for this series. &lt;code&gt;https://examplePublicKey@o4500000000000000.ingest.sentry.io/…&lt;/code&gt; and &lt;code&gt;cf_demo_…&lt;/code&gt; are demo values that connect to nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary.&lt;/strong&gt; The "pool" is the org-wide store (Global Keys in the console). The "common value" is the value set without an environment: Common in the console, &lt;code&gt;_all&lt;/code&gt; in the CLI. An "override" is a value set for one environment. "Binding" is a project declaring that it receives a pool key; an "alias" is the name it receives it under. An "exit code" is the number a command returns when it ends: 0 for success, anything else for failure. The "managed block" is the part of &lt;code&gt;.env&lt;/code&gt; that dotvault writes, between the &lt;code&gt;# BEGIN&lt;/code&gt; and &lt;code&gt;# END&lt;/code&gt; lines; lines you wrote by hand stay outside it. Unbinding (Unbind in the console, &lt;code&gt;global drop&lt;/code&gt; in the CLI) stops a project receiving a pool key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Project keys versus pool keys
&lt;/h2&gt;

&lt;p&gt;Every key up to part 4 lived inside a project, inside an environment. A pool key lives in neither.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Project key&lt;/th&gt;
&lt;th&gt;Pool key (Global Keys)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where&lt;/td&gt;
&lt;td&gt;project × environment (the Common (_shared) layer from part 3 is still inside the project)&lt;/td&gt;
&lt;td&gt;one pool per org, outside every project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Written with&lt;/td&gt;
&lt;td&gt;the project's Keys tab, &lt;code&gt;set&lt;/code&gt; / &lt;code&gt;import&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Global Keys in the sidebar, &lt;code&gt;global set&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value layers&lt;/td&gt;
&lt;td&gt;environment value and the project's Common (_shared) value&lt;/td&gt;
&lt;td&gt;common value (Common / &lt;code&gt;_all&lt;/code&gt;) and per-environment overrides&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivered to&lt;/td&gt;
&lt;td&gt;that project&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;only projects that bind it&lt;/strong&gt;; nothing is delivered on its own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Received with&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pull&lt;/code&gt; / &lt;code&gt;run&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the same &lt;code&gt;pull&lt;/code&gt; / &lt;code&gt;run&lt;/code&gt;, no extra step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same name on both sides&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;the project's value wins; the pool is the weakest layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seeing the value&lt;/td&gt;
&lt;td&gt;Reveal on the Keys tab, &lt;code&gt;pull&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;never in the console; only in the pulled file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The notice at the top of the Global Keys page says the same thing: "This is an org-shared secret pool that isn't tied to any project. … Only the ones a project explicitly binds from its own Global keys tab are included in that project's pull. … This page never displays the values themselves — check with dotvault global keys or dotvault pull."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Putting a key in the pool from the console
&lt;/h2&gt;

&lt;p&gt;Open Global Keys in the sidebar. It starts empty.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl6vi2hetkk1vsjml248v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl6vi2hetkk1vsjml248v.png" alt="The empty Global Keys page (English UI). The notice at the top, the Add a key heading with &amp;quot;The value you add is registered as the common value (_all). Per-env overrides are set afterwards from &amp;quot;Update value&amp;quot; on the row.&amp;quot;, the Key, Value and Description fields with an Add key button, and &amp;quot;No keys yet. Add one from the form above.&amp;quot; under Keys in the pool." width="800" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fill in the name, the value and a description under Add a key. Names may use letters, digits and &lt;code&gt;_&lt;/code&gt;, and must start with a letter or &lt;code&gt;_&lt;/code&gt; (e.g. SENTRY_DSN).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvhh65528960djy0yeki.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvhh65528960djy0yeki.png" alt="Add a key filled in with SENTRY_DSN, a value and the description &amp;quot;Sentry DSN shared by every product&amp;quot;. The Add key button is enabled." width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add key shows "Added SENTRY_DSN (common value)." and one row appears under Keys in the pool with a Common tag in the COMMON / OVERRIDE column. This is the common value: development, staging and production all receive it.&lt;/p&gt;

&lt;p&gt;To give production a different value, press Update value on the row.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnmy8kwt192cozri147tn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnmy8kwt192cozri147tn.png" alt="Update value opened: the target select (Common (_all) / development / staging / production) set to production, a new value typed in, and the Update button." width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The choices are Common (_all) and the three environments. Choose production, type the value and press Update: "Updated the value of SENTRY_DSN (production)."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fokptqvt5em121zxkwky7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fokptqvt5em121zxkwky7.png" alt="The Keys in the pool table (English UI). The SENTRY_DSN row with the description &amp;quot;Sentry DSN shared by every product&amp;quot;, Common and production tags in the COMMON / OVERRIDE column, Update value / Delete override / Delete key buttons under ACTIONS and the notice &amp;quot;Updated the value of SENTRY_DSN (production).&amp;quot;" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two tags now: Common and production. It means "production gets the override, every other environment gets the common value". A Delete override button appears once an override exists.&lt;/p&gt;

&lt;p&gt;The page never shows a value. There is no equivalent of the Reveal button from part 4. The way to check what you typed is &lt;code&gt;pull&lt;/code&gt;, in section 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Putting a key in the pool from the CLI
&lt;/h2&gt;

&lt;p&gt;The same thing from the terminal. Pool commands live under &lt;code&gt;dotvault global&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpupgql67l0eiwx9gfp0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpupgql67l0eiwx9gfp0.png" alt="Terminal screen of the pool commands (recomposed). dotvault whoami prints the email and &amp;quot;org: org_personal_f4785468-… Personal (personal, org_admin)&amp;quot;; dotvault global keys fails with &amp;quot;ORG_CONTEXT_REQUIRED: X-Org-Context header required to resolve an org for this session&amp;quot;; with --org it lists SENTRY_DSN with envs common,production; global set CLOUDFLARE_API_TOKEN=… prints &amp;quot;set 1 key(s) in the global pool [_all]&amp;quot;; with -E production it prints &amp;quot;[production]&amp;quot;; global describe prints &amp;quot;described CLOUDFLARE_API_TOKEN in the global pool [_all]&amp;quot;; the final global keys lists two rows." width="799" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first &lt;code&gt;dotvault global keys&lt;/code&gt; fails. &lt;code&gt;ORG_CONTEXT_REQUIRED&lt;/code&gt; means the CLI cannot tell which org's pool you mean: the &lt;code&gt;global&lt;/code&gt; commands that work on the pool do not pick an org from your login, even when you belong to only one, and since the pool belongs to no project there is no project to derive it from. Add &lt;code&gt;--org &amp;lt;id&amp;gt;&lt;/code&gt;. The id is on the &lt;code&gt;org:&lt;/code&gt; line of &lt;code&gt;dotvault whoami&lt;/code&gt;. A Personal org has a long &lt;code&gt;org_personal_…&lt;/code&gt; id; you type it once and recall it from shell history.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;global set KEY=VALUE&lt;/code&gt; writes the common value (&lt;code&gt;[_all]&lt;/code&gt;) without &lt;code&gt;-E&lt;/code&gt;, and a production override with &lt;code&gt;-E production&lt;/code&gt;. &lt;code&gt;global describe KEY "text"&lt;/code&gt; attaches a description. The envs column of &lt;code&gt;global keys&lt;/code&gt; reads &lt;code&gt;common,production&lt;/code&gt;: whether a common value exists, and which environments carry an override.&lt;/p&gt;

&lt;p&gt;At this point the pool holds &lt;code&gt;SENTRY_DSN&lt;/code&gt;, added in the console, and &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;, added from the CLI, each with a common value and a production override. The console shows the CLI's key and the CLI lists the console's key, as the same rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Binding a key to a project
&lt;/h2&gt;

&lt;p&gt;Putting a key in the pool delivers nothing to &lt;code&gt;demo-app&lt;/code&gt;. The project has to opt in.&lt;/p&gt;

&lt;p&gt;From the CLI, in the project directory: &lt;code&gt;dotvault global use &amp;lt;KEY&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd3qxd4yf6lm2vep64rz2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd3qxd4yf6lm2vep64rz2.png" alt="Terminal screen of binding (recomposed). dotvault global status prints &amp;quot;project=demo-app: no global keys bound.&amp;quot;; global use SENTRY_DSN prints &amp;quot;Bound SENTRY_DSN to demo-app.&amp;quot;; global use CLOUDFLARE_API_TOKEN --as CF_API_TOKEN prints &amp;quot;Bound CLOUDFLARE_API_TOKEN (as CF_API_TOKEN) to demo-app.&amp;quot;; global use GH_TOKEN prints &amp;quot;Bound GH_TOKEN to demo-app.&amp;quot; followed by &amp;quot;Warning: the global project has no value yet for: GH_TOKEN&amp;quot;; global status lists three lines (CLOUDFLARE_API_TOKEN -&gt; CF_API_TOKEN, GH_TOKEN, SENTRY_DSN)." width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--as CF_API_TOKEN&lt;/code&gt; renames the key on the way in. The pool still calls it &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;; &lt;code&gt;demo-app&lt;/code&gt;'s &lt;code&gt;.env&lt;/code&gt; gets &lt;code&gt;CF_API_TOKEN=…&lt;/code&gt;. Use it when the application already reads a different name.&lt;/p&gt;

&lt;p&gt;The third command binds &lt;code&gt;GH_TOKEN&lt;/code&gt;, a name that is not in the pool. It says "Bound" and then warns "the global project has no value yet for: GH_TOKEN". The binding is accepted; with no value behind it, nothing is pulled (section 5 confirms that).&lt;/p&gt;

&lt;p&gt;In the console, binding state is the BOUND AS column of the project's Global keys tab (section 6b binds one from there, with an alias).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;global status&lt;/code&gt;, &lt;code&gt;global use&lt;/code&gt; and &lt;code&gt;global drop&lt;/code&gt; run in the project directory or take &lt;code&gt;-p &amp;lt;project&amp;gt;&lt;/code&gt;. In a directory with no &lt;code&gt;project-config.json&lt;/code&gt; and no &lt;code&gt;-p&lt;/code&gt;, you get the VALIDATION_ERROR in section 9.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Pulling
&lt;/h2&gt;

&lt;p&gt;In the &lt;code&gt;demo-app&lt;/code&gt; directory, &lt;code&gt;dotvault pull&lt;/code&gt;. No extra flag.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ba7m1px6njfpdi79syb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ba7m1px6njfpdi79syb.png" alt="Terminal screen of dotvault pull (recomposed). dotvault pull prints &amp;quot;Wrote 7 keys to /work/demo-app/.env&amp;quot;, &amp;quot;Wrote 1 keys to /work/demo-app/.env.local&amp;quot; and &amp;quot;2 files synced, 0 excluded, 8 keys total&amp;quot;; cat .env shows the hand-written PORT=3000, then the managed block with CF_API_TOKEN=cf_demo_…, SENTRY_DSN=https://examplePublicKey@…, API_BASE_URL, DATABASE_URL, FEATURE_FLAGS, REDIS_URL and SMTP_PASSWORD, seven lines. cd ../prod-check &amp;amp;&amp;amp; dotvault pull -E production prints &amp;quot;Wrote 3 keys&amp;quot; and &amp;quot;1 file synced, 1 excluded, 3 keys total&amp;quot;; cat .env shows CF_API_TOKEN=cf_demo_prod_…, SENTRY_DSN=https://prodPublicKey@… and API_BASE_URL." width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The development &lt;code&gt;.env&lt;/code&gt; went from 5 keys to 7. The two new ones, &lt;code&gt;CF_API_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN&lt;/code&gt;, sit at the top of the managed block (between &lt;code&gt;# BEGIN&lt;/code&gt; and &lt;code&gt;# END&lt;/code&gt;); the project's own keys follow in name order. &lt;code&gt;GH_TOKEN&lt;/code&gt;, bound in section 4 with no value in the pool, is not written and there is no warning about it.&lt;/p&gt;

&lt;p&gt;The "7 keys" in the output includes the two from the pool. The keys from the pool sit at the top of the managed block, and &lt;code&gt;global status&lt;/code&gt; shows which ones are bound.&lt;/p&gt;

&lt;p&gt;The lower half is production. &lt;code&gt;pull&lt;/code&gt; rewrites the managed block of &lt;code&gt;.env&lt;/code&gt; in the directory you run it from, with the values of the environment you name (that is why the managed block in &lt;code&gt;prod-check&lt;/code&gt; below holds only the 3 production keys). Running &lt;code&gt;-E production&lt;/code&gt; inside &lt;code&gt;demo-app&lt;/code&gt; would drop the development values such as &lt;code&gt;DATABASE_URL&lt;/code&gt; from the block and put production values in your local &lt;code&gt;.env&lt;/code&gt;. So I checked from a separate directory (&lt;code&gt;prod-check&lt;/code&gt;) holding only a copy of &lt;code&gt;project-config.json&lt;/code&gt;, and ran &lt;code&gt;pull -E production&lt;/code&gt; there. &lt;code&gt;demo-app&lt;/code&gt; has never had a key in production. &lt;code&gt;pull&lt;/code&gt; still succeeds and writes 3 keys: &lt;code&gt;CF_API_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN&lt;/code&gt; with their production override values (&lt;code&gt;cf_demo_prod_…&lt;/code&gt; and &lt;code&gt;prodPublicKey&lt;/code&gt;), and &lt;code&gt;API_BASE_URL&lt;/code&gt; from the project's Common (_shared) layer set in part 3. "1 excluded" means &lt;code&gt;.env.local&lt;/code&gt;, which has no production keys, was not written.&lt;/p&gt;

&lt;p&gt;I pulled staging too. Staging has no override, so &lt;code&gt;CF_API_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN&lt;/code&gt; arrive with their common values (image omitted; the raw output is kept). An override applies to its environment and the common value applies everywhere else, and the pulled files bear that out.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run&lt;/code&gt; behaves the same way: the last screen in section 6b shows &lt;code&gt;dotvault run -- sh -c 'printenv CF_API_TOKEN SENTRY_DSN_WEB'&lt;/code&gt; printing both values.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. When the project has a key of the same name
&lt;/h2&gt;

&lt;p&gt;With the pool's &lt;code&gt;SENTRY_DSN&lt;/code&gt; still bound, put a key of the same name in &lt;code&gt;demo-app&lt;/code&gt;'s development.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi4znpniseh21r9wwq43m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi4znpniseh21r9wwq43m.png" alt="Terminal screen of the name collision (recomposed). dotvault set SENTRY_DSN=https://projectOwnKey@… prints &amp;quot;set SENTRY_DSN (v1) in .env [demo-app/development]&amp;quot;; dotvault pull writes 7 keys; grep -n SENTRY .env shows line 6 with the projectOwnKey value; dotvault global drop GH_TOKEN and global drop SENTRY_DSN print &amp;quot;Dropped … from demo-app.&amp;quot;; global status lists only CLOUDFLARE_API_TOKEN -&gt; CF_API_TOKEN; dotvault delete SENTRY_DSN prints &amp;quot;deleted 1 keys from .env&amp;quot;; dotvault pull writes 6 keys; grep shows only line 5, CF_API_TOKEN." width="800" height="563"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;pull&lt;/code&gt;, the &lt;code&gt;SENTRY_DSN&lt;/code&gt; line carries the project's &lt;code&gt;projectOwnKey&lt;/code&gt; value. The key count stays at 7: the pool's copy was replaced, not added to. The line keeps its bound position near the top of the managed block; only the value is the project's.&lt;/p&gt;

&lt;p&gt;The pool is the weakest layer. A project environment value beats it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;global drop&lt;/code&gt; stops receiving. In the console, the Unbind button on the project's Global keys tab is the same operation (the CLI help describes drop as "Unbind an org global key from this project."). After dropping &lt;code&gt;GH_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN&lt;/code&gt;, &lt;code&gt;global status&lt;/code&gt; is down to the one &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; line. I then removed the project's own &lt;code&gt;SENTRY_DSN&lt;/code&gt; with &lt;code&gt;dotvault delete&lt;/code&gt;, and &lt;code&gt;pull&lt;/code&gt; is back to 6 keys (the 5 from part 5 plus &lt;code&gt;CF_API_TOKEN&lt;/code&gt;). Dropping only removes the project's binding; the key stays in the pool.&lt;/p&gt;

&lt;p&gt;Before 6b, remove the production override of &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; from the CLI: &lt;code&gt;dotvault global delete CLOUDFLARE_API_TOKEN -E production --org &amp;lt;id&amp;gt;&lt;/code&gt; prints "deleted 1 key(s) from the global pool [production]", and the next &lt;code&gt;global keys&lt;/code&gt; shows &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; with envs &lt;code&gt;common&lt;/code&gt; only (both outputs are on the last terminal screen of section 7). That is why the &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; row on the 6b screens shows Common only.&lt;/p&gt;

&lt;h3&gt;
  
  
  6b. Rebinding from the console, with an alias
&lt;/h3&gt;

&lt;p&gt;Now bind the dropped &lt;code&gt;SENTRY_DSN&lt;/code&gt; again, this time from the console and under a different name. In the console, it is the project's Global keys tab. Every pool key is listed, and the BOUND AS column says whether this project is bound to it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygpde52eb8qqit7s37gx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygpde52eb8qqit7s37gx.png" alt="The Global keys tab of project demo-app (English UI). Under the notice, &amp;quot;Keys available in the org's shared pool, and whether demo-app is bound to each one.&amp;quot; and a table with CLOUDFLARE_API_TOKEN (Common, BOUND AS CF_API_TOKEN, an Unbind button) and SENTRY_DSN (Common and production, the alias field being filled with SENTRY_DSN_WEB, a Bind button)." width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SENTRY_DSN&lt;/code&gt; was just dropped, so its row shows a Bind button. The &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; binding made from the CLI shows up here as CF_API_TOKEN. Typing &lt;code&gt;SENTRY_DSN_WEB&lt;/code&gt; into the alias (optional) field of the &lt;code&gt;SENTRY_DSN&lt;/code&gt; row and pressing Bind gives "Bound SENTRY_DSN." and fills BOUND AS with &lt;code&gt;SENTRY_DSN_WEB&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wmg7gb0ofnx3ss9t027.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wmg7gb0ofnx3ss9t027.png" alt="The Global keys tab after binding. The &amp;quot;Bound SENTRY_DSN.&amp;quot; notice, both rows showing a name under BOUND AS (CF_API_TOKEN, SENTRY_DSN_WEB) and Unbind buttons." width="799" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A project's Keys tab lists the keys set in that project: in development, the same 4 keys as at the end of part 5 (the ones set as development values). In part 5, &lt;code&gt;.env&lt;/code&gt; had 5 because the fifth, &lt;code&gt;API_BASE_URL&lt;/code&gt;, is the project's Common (_shared) value from part 3. The places that show binding state are the Global keys tab and &lt;code&gt;global status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A binding made in the console shows up in &lt;code&gt;global status&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ozghst0c6rkxm09ab84.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ozghst0c6rkxm09ab84.png" alt="Terminal screen after binding in the console (recomposed). dotvault global status prints &amp;quot;CLOUDFLARE_API_TOKEN -&gt; CF_API_TOKEN&amp;quot; and &amp;quot;SENTRY_DSN -&gt; SENTRY_DSN_WEB&amp;quot;; dotvault pull writes 7 keys; grep shows line 5 CF_API_TOKEN=… and line 6 SENTRY_DSN_WEB=…; dotvault run -- sh -c 'printenv CF_API_TOKEN SENTRY_DSN_WEB' prints the two values; in another directory dotvault global status -p demo-app works." width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;global status&lt;/code&gt; shows two lines, &lt;code&gt;CLOUDFLARE_API_TOKEN -&amp;gt; CF_API_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN -&amp;gt; SENTRY_DSN_WEB&lt;/code&gt;; &lt;code&gt;pull&lt;/code&gt; writes 7 keys, and &lt;code&gt;.env&lt;/code&gt; gets &lt;code&gt;SENTRY_DSN_WEB=…&lt;/code&gt;. Part 7 starts from this state.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Removing from the pool
&lt;/h2&gt;

&lt;p&gt;Removal has two levels: one override, or the whole key.&lt;/p&gt;

&lt;p&gt;In the console, Delete override on the row opens an environment select and a Delete override button. The CLI equivalent, removing only the production override, is &lt;code&gt;global delete … -E production&lt;/code&gt; below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyvizm87eda405zi7a3x5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyvizm87eda405zi7a3x5.png" alt="Delete override opened under the SENTRY_DSN row, before pressing its button: an environment select set to production and a Delete override button." width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Delete key removes the common value and every override. Pressing it shows "This deletes the common value and every override (1)." under the row, with the number of overrides, and a second Delete button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa8w3n14v89g0djb5tfjo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa8w3n14v89g0djb5tfjo.png" alt="Delete key opened under the SENTRY_DSN row: &amp;quot;This deletes the common value and every override (1).&amp;quot; and a red Delete button." width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pressing Delete then raises the browser's own confirmation, which names the key being deleted. On a throwaway key created for this, TMP_DELETE_ME, it read "Delete TMP_DELETE_ME entirely (the common value and every override)? This cannot be undone." (not capturable as an image, so quoted).&lt;/p&gt;

&lt;p&gt;From the CLI, &lt;code&gt;global delete KEY&lt;/code&gt; removes the common value and &lt;code&gt;global delete KEY -E production&lt;/code&gt; removes only the production override. The screen below captures the &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; override removal from the end of section 6. Just before it, I also tried a bare &lt;code&gt;set&lt;/code&gt;, which is why the three-row &lt;code&gt;global keys&lt;/code&gt; in the middle still shows a production override on &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgbgy9n1f2b1oz9u7obhi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgbgy9n1f2b1oz9u7obhi.png" alt="Terminal screen of a bare set and the delete commands (recomposed). In /work/nowhere, a directory with no project-config.json, dotvault set BARE_TEST=1 prints &amp;quot;set 1 key(s) in the global pool [_all]&amp;quot;; global keys lists three rows including BARE_TEST; global delete BARE_TEST prints &amp;quot;deleted 1 key(s) from the global pool [_all]&amp;quot;; global delete CLOUDFLARE_API_TOKEN -E production prints &amp;quot;[production]&amp;quot;; the final global keys shows CLOUDFLARE_API_TOKEN with envs common only." width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first two commands on that screen are a separate point. In a directory with no &lt;code&gt;project-config.json&lt;/code&gt;, a plain &lt;code&gt;dotvault set BARE_TEST=1&lt;/code&gt;, without &lt;code&gt;global&lt;/code&gt;, prints "set 1 key(s) in the global pool [_all]". A &lt;code&gt;set&lt;/code&gt; that cannot resolve a project writes to the pool, and it does not need &lt;code&gt;--org&lt;/code&gt;: with a single org membership, the bare &lt;code&gt;set&lt;/code&gt; resolves the org on its own. The &lt;code&gt;global&lt;/code&gt; commands take &lt;code&gt;--org&lt;/code&gt;, as in section 3. The &lt;code&gt;set&lt;/code&gt; from part 4, typed somewhere without a &lt;code&gt;project-config.json&lt;/code&gt;, lands in the pool instead of a project. If that happens by accident, &lt;code&gt;global keys&lt;/code&gt; shows it and &lt;code&gt;global delete&lt;/code&gt; removes it.&lt;/p&gt;

&lt;p&gt;The last &lt;code&gt;global keys&lt;/code&gt; shows &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; with &lt;code&gt;common&lt;/code&gt; only: deleting the production override leaves the common value in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Permissions and service tokens
&lt;/h2&gt;

&lt;p&gt;This part runs in a Personal org (an org of one), so there are no permission settings. Service tokens for CI come in part 7, and who can write to the pool in an org with invited members comes in part 9, both with console screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. When it fails, and when a wrong name still prints success
&lt;/h2&gt;

&lt;p&gt;The failures from this part, and the commands that print a success line even when the name is wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0n8we3echwfppkh1n6u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0n8we3echwfppkh1n6u.png" alt="Terminal screen of failures (recomposed). dotvault global keys prints ORG_CONTEXT_REQUIRED and exit 1; global describe NO_SUCH_KEY &amp;quot;does not exist&amp;quot; --org … prints &amp;quot;described NO_SUCH_KEY in the global pool [_all]&amp;quot; and exit 0; global drop NOT_BOUND prints &amp;quot;Dropped NOT_BOUND from demo-app.&amp;quot; and exit 0; global status in a directory without a config prints VALIDATION_ERROR (No project-config.json found …) and exit 6; global set --org … prints &amp;quot;error: missing required argument 'pairs'&amp;quot; and exit 1." width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;global keys&lt;/code&gt; / &lt;code&gt;global set&lt;/code&gt; without &lt;code&gt;--org&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ORG_CONTEXT_REQUIRED: X-Org-Context header required to resolve an org for this session&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;global describe NO_SUCH_KEY "…"&lt;/code&gt; (name not in the pool)&lt;/td&gt;
&lt;td&gt;described NO_SUCH_KEY in the global pool &lt;a href="https://dev.tono%20key%20is%20created"&gt;_all&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;global use GH_TOKEN&lt;/code&gt; (name not in the pool)&lt;/td&gt;
&lt;td&gt;Bound GH_TOKEN … + Warning: the global project has no value yet for: GH_TOKEN&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;global drop NOT_BOUND&lt;/code&gt; (never bound)&lt;/td&gt;
&lt;td&gt;Dropped NOT_BOUND from demo-app.&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;global status&lt;/code&gt; with no &lt;code&gt;project-config.json&lt;/code&gt; and no &lt;code&gt;-p&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;VALIDATION_ERROR: No project-config.json found … For the org-wide pool use &lt;code&gt;dotvault global …&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;global set --org …&lt;/code&gt; with no KEY=VALUE&lt;/td&gt;
&lt;td&gt;error: missing required argument 'pairs'&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first message means the CLI could not tell which org's pool you meant. Run it again with &lt;code&gt;--org &amp;lt;id&amp;gt;&lt;/code&gt;, as in the &lt;code&gt;global keys --org …&lt;/code&gt; runs in section 3.&lt;/p&gt;

&lt;p&gt;In the second and fourth rows, describing a name that is not in the pool, or dropping a key that was never bound, prints a success line and exits 0. Check the names with &lt;code&gt;global keys&lt;/code&gt; and &lt;code&gt;global status&lt;/code&gt;. The &lt;code&gt;use&lt;/code&gt; command (third row) adds a warning.&lt;/p&gt;

&lt;p&gt;This table is the output of CLI 0.15.7. To catch a mistyped name or a missing bind, check the &lt;code&gt;global status&lt;/code&gt; and &lt;code&gt;pull&lt;/code&gt; output as in sections 4 and 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 7 hands secrets to CI without putting a person's password there. A service token issued in the console lets GitHub Actions pull the production values, and the &lt;code&gt;CF_API_TOKEN&lt;/code&gt; and &lt;code&gt;SENTRY_DSN_WEB&lt;/code&gt; bound in this part show up in that pull as well.&lt;/p&gt;




&lt;p&gt;dotvault stores the secrets in your .env and config files server-side and hands them to any authorised developer, CI job or AI agent with a single command (&lt;a href="https://dotvault.io" rel="noopener noreferrer"&gt;https://dotvault.io&lt;/a&gt;). It will be released free of charge when this series ends, and the steps in these articles are the steps you will use on day one.&lt;/p&gt;

&lt;p&gt;If you find this useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotvault</category>
      <category>secrets</category>
      <category>devops</category>
      <category>cli</category>
    </item>
    <item>
      <title>Still sending .npmrc over Slack? Push whole files, scan before you commit</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Wed, 04 Nov 2026 10:02:21 +0000</pubDate>
      <link>https://dev.to/uehara/still-sending-npmrc-over-slack-push-whole-files-scan-before-you-commit-maa</link>
      <guid>https://dev.to/uehara/still-sending-npmrc-over-slack-push-whole-files-scan-before-you-commit-maa</guid>
      <description>&lt;p&gt;This is part 5 of the series "dotvault, Start to Finish: One Command from Your Laptop to CI, Your Team, and Your AI Agents." dotvault will be released free of charge when this series ends.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dotvault scan&lt;/code&gt; does not catch &lt;code&gt;SMTP_PASSWORD=very-secret-mail-password-2026&lt;/code&gt; in a &lt;code&gt;.env&lt;/code&gt; file. It catches &lt;code&gt;sk_live_…&lt;/code&gt; on the next line, &lt;code&gt;AKIA…&lt;/code&gt; in a JavaScript file, &lt;code&gt;ghp_…&lt;/code&gt; in a shell script and &lt;code&gt;est_…&lt;/code&gt; in a GitHub Actions workflow, and it exits 1 so a pre-commit hook can stop the commit. That is the division of labour this part is about: &lt;code&gt;.gitignore&lt;/code&gt; keeps &lt;code&gt;.env&lt;/code&gt; out of git, &lt;code&gt;scan&lt;/code&gt; keeps token-shaped strings out of your code, and &lt;code&gt;dotvault push&lt;/code&gt; moves the files that do not split into keys and values, such as &lt;code&gt;.npmrc&lt;/code&gt; and a kubeconfig, through the same channel as &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is part 5 of 12. The takeaway: an &lt;code&gt;.npmrc&lt;/code&gt; or a kubeconfig can be stored in one command and arrives on the next machine with the same &lt;code&gt;pull&lt;/code&gt; or &lt;code&gt;run&lt;/code&gt; that brings &lt;code&gt;.env&lt;/code&gt;. Screens are from the production console and CLI 0.15.7, continuing with the &lt;code&gt;demo-app&lt;/code&gt; project from parts 2 to 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A file that is not key=value pairs goes in with &lt;code&gt;dotvault push &amp;lt;file&amp;gt;&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;.npmrc&lt;/code&gt;, a kubeconfig, a YAML configuration: text files you want kept whole. The &lt;code&gt;import&lt;/code&gt; command and the Upload tab from part 4 do something else: they parse &lt;code&gt;KEY=VALUE&lt;/code&gt; lines and register keys one by one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destination and pointer are registered with the file.&lt;/strong&gt; &lt;code&gt;--as .kube/config&lt;/code&gt; sets where the file lands on the receiving side; &lt;code&gt;--pointer KUBECONFIG&lt;/code&gt; names the environment variable &lt;code&gt;run&lt;/code&gt; sets to the temporary path. &lt;code&gt;.npmrc&lt;/code&gt; got &lt;code&gt;NPM_CONFIG_USERCONFIG&lt;/code&gt; without any option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The receiving side uses &lt;code&gt;dotvault pull&lt;/code&gt; or &lt;code&gt;dotvault run&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;pull&lt;/code&gt; writes pushed files to their destination paths in the same run that writes &lt;code&gt;.env&lt;/code&gt;. &lt;code&gt;run&lt;/code&gt; writes them to a temporary directory, sets the pointer variables, and removes everything when the command exits. &lt;code&gt;pull --file&lt;/code&gt; selects one key file such as &lt;code&gt;.env.local&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Files tab in the console lists, copies to another environment and deletes.&lt;/strong&gt; Adding and downloading are done from the CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dotvault scan .&lt;/code&gt; works without logging in.&lt;/strong&gt; It looks for token-shaped strings (AWS access keys, GitHub tokens, dotvault service tokens and others: ten rules in all, listed in section 8), prints file, line, rule and a redacted excerpt, and exits 1 when it finds anything. Calling it from &lt;code&gt;.git/hooks/pre-commit&lt;/code&gt; turns that into a blocked commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scan&lt;/code&gt; is not a guard for &lt;code&gt;.env&lt;/code&gt; itself.&lt;/strong&gt; A plain &lt;code&gt;PASSWORD=value&lt;/code&gt; line is not matched. Keep &lt;code&gt;.env&lt;/code&gt; out of git with &lt;code&gt;.gitignore&lt;/code&gt;; use &lt;code&gt;scan&lt;/code&gt; for secrets that leak into code, CI configuration and Dockerfiles.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you read on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where we start.&lt;/strong&gt; Part 4 left &lt;code&gt;demo-app&lt;/code&gt; with &lt;code&gt;.env&lt;/code&gt; (5 keys) and &lt;code&gt;.env.local&lt;/code&gt; (1 key) in development. You can follow this part without having read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment.&lt;/strong&gt; Production console, CLI 0.15.7, the same Node 20 Linux container as part 2. The "other machine" is a second directory in that container (&lt;code&gt;machine-b&lt;/code&gt;) holding only &lt;code&gt;project-config.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal screens are recomposed.&lt;/strong&gt; What the terminal printed is reproduced verbatim in a cleaner frame. The raw output is kept as is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo account.&lt;/strong&gt; Project names, file contents and token strings were created for this series. &lt;code&gt;ghp_demo…&lt;/code&gt; and &lt;code&gt;AKIAIOSFODNN7EXAMPLE&lt;/code&gt; do not work anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary.&lt;/strong&gt; A "key" is a name and a value, such as &lt;code&gt;DATABASE_URL=…&lt;/code&gt;. A "file" is something registered whole with &lt;code&gt;push&lt;/code&gt;. A "pointer" is the environment variable &lt;code&gt;run&lt;/code&gt; sets to the temporary path of a file. An "exit code" is the number a command returns when it ends: 0 for success, anything else for failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Keys versus files
&lt;/h2&gt;

&lt;p&gt;dotvault stores two kinds of things.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit&lt;/td&gt;
&lt;td&gt;one name and one value (&lt;code&gt;DATABASE_URL=…&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;the whole content of a file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Written with&lt;/td&gt;
&lt;td&gt;+ Add key / Upload in the console, &lt;code&gt;set&lt;/code&gt; / &lt;code&gt;import&lt;/code&gt; in the CLI&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;push&lt;/code&gt; in the CLI only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Received with&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pull&lt;/code&gt; (written into &lt;code&gt;.env&lt;/code&gt; etc.) / &lt;code&gt;run&lt;/code&gt; (injected as environment variables)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pull&lt;/code&gt; (written to the destination path) / &lt;code&gt;run&lt;/code&gt; (written to a temp dir, pointer set)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Console&lt;/td&gt;
&lt;td&gt;Keys tab (view, write, delete)&lt;/td&gt;
&lt;td&gt;Files tab (list, copy to another env, delete; no add, no download)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;versions&lt;/code&gt; lists changes, &lt;code&gt;rollback&lt;/code&gt; restores an earlier value&lt;/td&gt;
&lt;td&gt;each &lt;code&gt;push&lt;/code&gt; adds one to the VERSION column in the console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.local&lt;/code&gt;, anything that is one setting per line&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.npmrc&lt;/code&gt;, kubeconfig, YAML configuration (text files)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;.npmrc&lt;/code&gt; looks like &lt;code&gt;KEY=VALUE&lt;/code&gt;, but a line such as &lt;code&gt;//npm.pkg.github.com/:_authToken=…&lt;/code&gt; makes a poor key name. Pushing the file as one piece is the natural fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pushing a file
&lt;/h2&gt;

&lt;p&gt;From the &lt;code&gt;demo-app&lt;/code&gt; directory, push &lt;code&gt;.npmrc&lt;/code&gt; and &lt;code&gt;~/.kube/config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7xsnduxf5lzazd2segv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7xsnduxf5lzazd2segv.png" alt="Terminal screen for dotvault push (recomposed): cat .npmrc shows three lines (registry, @acme:registry, _authToken); dotvault push .npmrc prints &amp;quot;pushed .npmrc (v1) → .npmrc [run sets NPM_CONFIG_USERCONFIG] (project=demo-app, env=development)&amp;quot;; dotvault push ~/.kube/config --as .kube/config --pointer KUBECONFIG prints &amp;quot;pushed .kube/config (v1) → .kube/config [run sets KUBECONFIG]&amp;quot;; dotvault files lists .kube/config and .npmrc with the pointer names in parentheses." width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading the output: &lt;code&gt;pushed .npmrc (v1) → .npmrc&lt;/code&gt; means the file is stored under the name &lt;code&gt;.npmrc&lt;/code&gt; as version 1 and will be written to &lt;code&gt;.npmrc&lt;/code&gt; on the receiving side. &lt;code&gt;[run sets NPM_CONFIG_USERCONFIG]&lt;/code&gt; means &lt;code&gt;run&lt;/code&gt; will put the temporary path into that variable. No option was given for &lt;code&gt;.npmrc&lt;/code&gt;; common files come with a pointer already assigned.&lt;/p&gt;

&lt;p&gt;The kubeconfig got both options by hand. &lt;code&gt;--as .kube/config&lt;/code&gt; puts it at &lt;code&gt;.kube/config&lt;/code&gt; inside the project directory on the receiving side, and &lt;code&gt;--pointer KUBECONFIG&lt;/code&gt; makes &lt;code&gt;run&lt;/code&gt; set &lt;code&gt;KUBECONFIG&lt;/code&gt;. For a file that lives outside the project, such as &lt;code&gt;~/.kube/config&lt;/code&gt;, spell out both options like this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dotvault files&lt;/code&gt; lists the files registered for this environment as "name → destination (pointer)". Pushing the same file again produces v2.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Files tab
&lt;/h2&gt;

&lt;p&gt;The project page in the console has a Files tab. Before any push it is empty, with the line "Add one from the CLI with dotvault push".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqzfxfl4nfa7iigw3h5d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqzfxfl4nfa7iigw3h5d.png" alt="Empty Files tab of demo-app (English UI): the environment switch with development selected, a &amp;quot;Copy all files in this env…&amp;quot; button on the right, and the text &amp;quot;No files in this env yet. Add one from the CLI with dotvault push&amp;quot;." width="799" height="245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Adding and downloading files are done from the CLI (push to add, pull or run to receive). After the push, the tab shows a table.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wn3oynlem8pv2ikro3x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wn3oynlem8pv2ikro3x.png" alt="Files tab after pushing (English UI): development (3) selected; a table with FILE PATH, VERSION and UPDATED columns listing .kube/config (1), .npmrc (2) and cert.der (1); each row has Copy… and Delete buttons." width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cert.der&lt;/code&gt; is a file I pushed to check that &lt;code&gt;push&lt;/code&gt; is text only (section 9); it is deleted below. &lt;code&gt;.npmrc&lt;/code&gt; is at version 2 because I added a line and pushed it again during the session.&lt;/p&gt;

&lt;p&gt;"Copy all files in this env…" opens a dialog for copying every file to another environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8x87b6qyn9j703yif1ws.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8x87b6qyn9j703yif1ws.png" alt="The &amp;quot;Copy all files in development…&amp;quot; dialog (English UI): a Copy to selector showing staging, an unchecked &amp;quot;Overwrite files that already exist in staging&amp;quot; checkbox, and Cancel and Copy buttons." width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same idea as Compare envs in part 3: a file that already exists in the target is left alone unless the checkbox is ticked. I pressed Cancel here, because I did not want anything created in staging.&lt;/p&gt;

&lt;p&gt;Delete on a row opens a browser confirmation ("Delete development/cert.der? This cannot be undone."). After OK, the line "Deleted development/cert.der." appears and the table has two rows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjskdhucduqugrcelwc4h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjskdhucduqugrcelwc4h.png" alt="Files tab after deleting cert.der (English UI): development (2) selected; the table lists .kube/config (1) and .npmrc (2)." width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Pulling on another machine
&lt;/h2&gt;

&lt;p&gt;The receiving side: an empty directory with only &lt;code&gt;project-config.json&lt;/code&gt;, and &lt;code&gt;dotvault pull&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyh0u5qbopfxmp4mm44w9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyh0u5qbopfxmp4mm44w9.png" alt="Terminal screen for dotvault pull in another directory (recomposed): after mkdir -p .kube, ls -la shows .kube and project-config.json; dotvault pull prints two lines for .env (5 keys) and .env.local (1 keys) and &amp;quot;2 files synced, 0 excluded, 6 keys total&amp;quot;; ls -la now also shows .env, .env.local and .npmrc with .npmrc at -rw-------; ls -la .kube shows the config written inside .kube; head -3 .kube/config shows apiVersion: v1, kind: Config, clusters:." width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pull&lt;/code&gt; output mentions &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.local&lt;/code&gt; only, but &lt;code&gt;ls -la&lt;/code&gt; shows &lt;code&gt;.npmrc&lt;/code&gt; and &lt;code&gt;.kube/config&lt;/code&gt; as well. Pushed files are written to their destination on every &lt;code&gt;pull&lt;/code&gt;, and a newly written file gets the same &lt;code&gt;-rw-------&lt;/code&gt; permissions as &lt;code&gt;.env&lt;/code&gt; (owner read and write only).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mkdir -p .kube&lt;/code&gt; at the top is explained in section 9. When the destination directory does not exist, &lt;code&gt;pull&lt;/code&gt; writes none of the pushed files and prints the same output.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. run: a temporary directory instead
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pull&lt;/code&gt; leaves the files on the machine. For CI, or for a short task, &lt;code&gt;run&lt;/code&gt; fits better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk6x5z1zd54jacymifoqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk6x5z1zd54jacymifoqz.png" alt="Terminal screen for dotvault run (recomposed): dotvault run -- sh -c '…' prints KUBECONFIG=a temporary path under /tmp ending in config, an ls -la of that path (-rw-------, 279 bytes) and its first three lines (apiVersion: v1, kind: Config, clusters:); an ls -la of the same path after run says No such file or directory; a second run prints NPM_CONFIG_USERCONFIG=a temporary path under /tmp ending in .npmrc and npm config get @acme:registry returns https://npm.pkg.github.com/." width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run&lt;/code&gt; writes every file that has a pointer into a temporary directory under &lt;code&gt;/tmp&lt;/code&gt;, sets the pointer variable to that path and runs the command. When the command exits, the directory is removed. That is why the second &lt;code&gt;ls -la&lt;/code&gt; says "No such file or directory".&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;.npmrc&lt;/code&gt;, &lt;code&gt;npm config get @acme:registry&lt;/code&gt; returns &lt;code&gt;https://npm.pkg.github.com/&lt;/code&gt;: npm read the pushed &lt;code&gt;.npmrc&lt;/code&gt; through &lt;code&gt;NPM_CONFIG_USERCONFIG&lt;/code&gt;. There is no &lt;code&gt;.npmrc&lt;/code&gt; in the project directory.&lt;/p&gt;

&lt;p&gt;A file without a pointer (section 9's &lt;code&gt;cert.der&lt;/code&gt;: no &lt;code&gt;--pointer&lt;/code&gt;, no default) is written to its destination path for the duration of &lt;code&gt;run&lt;/code&gt; and removed afterwards. If you want a file to stay on the machine, use &lt;code&gt;pull&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. scan: finding secrets before they are committed
&lt;/h2&gt;

&lt;p&gt;The rest of this part is about not leaking rather than distributing. &lt;code&gt;dotvault scan &amp;lt;path&amp;gt;&lt;/code&gt; reads the files and directories you name and looks for token-shaped strings. Nothing is sent to the server. It works without a login: after &lt;code&gt;logout&lt;/code&gt; at the end of the session (&lt;code&gt;whoami&lt;/code&gt; says "Not logged in."), &lt;code&gt;scan .&lt;/code&gt; on the already-cleaned repository still ran and returned &lt;code&gt;No secrets found.&lt;/code&gt; rather than an authentication error.&lt;/p&gt;

&lt;p&gt;I built a small repository with three secrets: an AWS access key in &lt;code&gt;src/s3.js&lt;/code&gt;, a GitHub token in &lt;code&gt;deploy.sh&lt;/code&gt; and a dotvault service token in &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;. The same AWS key also sits inside &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwwkuvw8ogdbkr8uxfzu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwwkuvw8ogdbkr8uxfzu.png" alt="Terminal screen for dotvault scan (recomposed): find . -type f | sort lists five files including node_modules/some-dep/index.js; dotvault scan . prints three lines, .github/workflows/ci.yml:2 [dotvault-service-token], deploy.sh:2 [github-pat] and src/s3.js:3 [aws-access-key-id], each with a redacted excerpt such as est_...ue (72 chars), then &amp;quot;3 potential secret(s) found.&amp;quot;; echo $? prints 1; dotvault scan src/ finds one; dotvault scan . in machine-b prints &amp;quot;No secrets found.&amp;quot; and 0." width="800" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each finding is "file:line [rule] description — excerpt". The excerpt is the first four characters, the last two and the length, so the secret itself never lands in your terminal scrollback or CI log. The key inside &lt;code&gt;node_modules&lt;/code&gt; is not reported; a fixed list of directories is skipped (section 8).&lt;/p&gt;

&lt;p&gt;The exit code is 1 when anything is found and 0 with &lt;code&gt;No secrets found.&lt;/code&gt; otherwise. Several paths can be given (&lt;code&gt;scan src/ tests/&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Stopping the commit
&lt;/h2&gt;

&lt;p&gt;Before a commit, git runs &lt;code&gt;.git/hooks/pre-commit&lt;/code&gt; if the file exists, and abandons the commit when that script exits with anything other than 0. &lt;code&gt;scan&lt;/code&gt; exits 1 when it finds something, so placing it there is enough.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyu13iujc3wxxqbw0mgrj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyu13iujc3wxxqbw0mgrj.png" alt="Terminal screen of a pre-commit hook blocking a commit (recomposed): cat .git/hooks/pre-commit shows #!/bin/sh and exec dotvault scan .; git add src deploy.sh .github .gitignore README.md &amp;amp;&amp;amp; git commit prints the same three findings as section 6 and &amp;quot;3 potential secret(s) found.&amp;quot;; git log --oneline says &amp;quot;fatal: your current branch 'main' does not have any commits yet&amp;quot;; after replacing the three literals with environment variables, git commit prints &amp;quot;No secrets found.&amp;quot; and &amp;quot;[main (root-commit) f2748e0] add deploy script&amp;quot;, &amp;quot;5 files changed, 11 insertions(+)&amp;quot;." width="799" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hook is two lines:&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="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;dotvault scan &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first commit prints the three findings and stops; &lt;code&gt;git log&lt;/code&gt; confirms nothing was recorded. After replacing the values with &lt;code&gt;process.env.AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;${{ secrets.DOTVAULT_TOKEN }}&lt;/code&gt;, the second commit goes through.&lt;/p&gt;

&lt;p&gt;This hook reads the whole working tree, not the staged diff, and that includes a &lt;code&gt;.env&lt;/code&gt; excluded by &lt;code&gt;.gitignore&lt;/code&gt;. If your local &lt;code&gt;.env&lt;/code&gt; holds a value with a known shape, such as a live Stripe key, every commit stops. In that case, and in a large repository, narrow it: &lt;code&gt;exec dotvault scan src/ .github/&lt;/code&gt;, leaving &lt;code&gt;.env&lt;/code&gt; out. To share the hook with a team, put the same line in a hook manager such as lefthook or husky instead of &lt;code&gt;.git/hooks&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. What scan looks for, and what it does not
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scan&lt;/code&gt; matches fixed patterns. CLI 0.15.7 ships ten rules.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;aws-access-key-id&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AKIA&lt;/code&gt; + 16 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aws-secret-access-key&lt;/td&gt;
&lt;td&gt;a quoted 40-character string near the word &lt;code&gt;aws&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;private-key&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;-----BEGIN PRIVATE KEY-----&lt;/code&gt;, optionally with RSA / EC / OPENSSH / DSA / PGP (ENCRYPTED PRIVATE KEY is not matched)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;github-pat&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ghp_&lt;/code&gt; + 36 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;github-fine-grained-pat&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;github_pat_&lt;/code&gt; + 60 or more characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;slack-token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;xoxb-&lt;/code&gt; and similar prefixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stripe-secret-key&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sk_live_&lt;/code&gt; + 24 or more characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;google-api-key&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AIza&lt;/code&gt; + 35 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dotvault-service-token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;est_&lt;/code&gt; + 32 characters + &lt;code&gt;.&lt;/code&gt; + 20 or more characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;generic-assignment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;secret&lt;/code&gt; / &lt;code&gt;token&lt;/code&gt; / &lt;code&gt;password&lt;/code&gt; / &lt;code&gt;passwd&lt;/code&gt; / &lt;code&gt;api_key&lt;/code&gt; (also &lt;code&gt;api-key&lt;/code&gt;, &lt;code&gt;apikey&lt;/code&gt;; case-insensitive), then &lt;code&gt;=&lt;/code&gt; or &lt;code&gt;:&lt;/code&gt;, then a quoted string of 16 or more characters with no whitespace&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A fixed set of things is skipped.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Skipped&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Directories&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;.next&lt;/code&gt;, &lt;code&gt;coverage&lt;/code&gt;, &lt;code&gt;cdk.out&lt;/code&gt;, &lt;code&gt;.turbo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large files&lt;/td&gt;
&lt;td&gt;over 5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary files&lt;/td&gt;
&lt;td&gt;a NUL byte in the first 8 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;.gitignore&lt;/code&gt; is not consulted; directories are skipped by name.&lt;/p&gt;

&lt;p&gt;Both tables come from reading the published CLI code; the directory, size and binary rules were also measured. A 6 MB text file and a 27-byte file containing a NUL byte, each with &lt;code&gt;AKIAIOSFODNN7EXAMPLE&lt;/code&gt; inside, both come back as &lt;code&gt;No secrets found.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9cwhpfxq027s3na65ha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9cwhpfxq027s3na65ha.png" alt="Terminal screen of scan's limits (recomposed): ls -la shows big.txt (6000021 bytes) and blob.bin (27 bytes); dotvault scan big.txt blob.bin prints &amp;quot;No secrets found.&amp;quot;; cat .env shows SMTP_PASSWORD=very-secret-mail-password-2026 and STRIPE_KEY=sk_live_demo…; cat docker-compose.yml shows POSTGRES_PASSWORD: &amp;quot;${POSTGRES_PASSWORD_FROM_ENV}&amp;quot;; dotvault scan . reports two findings, .env:2 [stripe-secret-key] and docker-compose.yml:4 [generic-assignment]." width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second half of that screen is the point of this part. &lt;code&gt;SMTP_PASSWORD=very-secret-mail-password-2026&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; is not reported: generic-assignment only matches quoted values, and plain &lt;code&gt;.env&lt;/code&gt; syntax has no quotes. &lt;code&gt;sk_live_…&lt;/code&gt; on the next line is reported, because it has the Stripe shape.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;scan&lt;/code&gt; is not the tool that stops you committing &lt;code&gt;.env&lt;/code&gt;. That is &lt;code&gt;.gitignore&lt;/code&gt;'s job. &lt;code&gt;scan&lt;/code&gt; stops the other accident: a token written straight into code, CI configuration or a Dockerfile.&lt;/p&gt;

&lt;p&gt;The reverse also happens. &lt;code&gt;POSTGRES_PASSWORD: "${POSTGRES_PASSWORD_FROM_ENV}"&lt;/code&gt; in &lt;code&gt;docker-compose.yml&lt;/code&gt; is a variable reference, not a value, but the quoted part is 16 characters or longer and it is reported. With this pattern, narrow the paths given to the pre-commit hook so the file is not included, or drop the quotes.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. When it fails
&lt;/h2&gt;

&lt;p&gt;The failures from this part, with the output and exit code for each.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ky39glz4yhk1lp4srxe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ky39glz4yhk1lp4srxe.png" alt="Terminal screen of what happens when things go wrong (recomposed): rm -rf .kube .npmrc &amp;amp;&amp;amp; dotvault pull prints the same two lines as section 4 and &amp;quot;2 files synced&amp;quot;; ls -la .npmrc .kube says No such file or directory for both; dotvault pull --file .kube/config prints &amp;quot;unexpected: Invalid env file name: &amp;quot;.kube/config&amp;quot;&amp;quot; and 99; dotvault pull --file .npmrc prints PROJECT_NOT_FOUND (server: No DDB secrets for .npmrc) and 5; dotvault push .yarnrc prints VALIDATION_ERROR (Could not read .yarnrc: ENOENT) and 6; dotvault files --env staging prints &amp;quot;No files synced.&amp;quot;; dotvault scan nope/ prints &amp;quot;No secrets found.&amp;quot; and 0." width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pull&lt;/code&gt; with the destination directory (&lt;code&gt;.kube&lt;/code&gt;) missing&lt;/td&gt;
&lt;td&gt;the two &lt;code&gt;.env&lt;/code&gt; lines and "2 files synced"; neither &lt;code&gt;.npmrc&lt;/code&gt; nor &lt;code&gt;.kube/config&lt;/code&gt; is written&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pull --file .kube/config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unexpected: Invalid env file name&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pull --file .npmrc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PROJECT_NOT_FOUND (server: No DDB secrets for .npmrc)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;push .yarnrc&lt;/code&gt; (file does not exist)&lt;/td&gt;
&lt;td&gt;VALIDATION_ERROR (Could not read … ENOENT)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;files --env staging&lt;/code&gt; (environment with no files)&lt;/td&gt;
&lt;td&gt;No files synced.&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;scan nope/&lt;/code&gt; (path does not exist)&lt;/td&gt;
&lt;td&gt;No secrets found.&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row is why section 4 starts with &lt;code&gt;mkdir -p .kube&lt;/code&gt;. If a destination includes a directory, create it on the receiving side first. Without it, none of the pushed files are written (&lt;code&gt;.npmrc&lt;/code&gt;, which comes after &lt;code&gt;.kube/config&lt;/code&gt;, is missing too). The "2" in "2 files synced" counts &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.local&lt;/code&gt;; pushed files are not in that number, so check with &lt;code&gt;ls&lt;/code&gt; that they arrived.&lt;/p&gt;

&lt;p&gt;Rows two and three show &lt;code&gt;pull --file&lt;/code&gt; used on pushed files. &lt;code&gt;pull --file&lt;/code&gt; selects one key file such as &lt;code&gt;.env.local&lt;/code&gt;. Pushed files arrive with &lt;code&gt;pull&lt;/code&gt; (everything) or &lt;code&gt;run&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The last row: a mistyped path returns "No secrets found." and 0. A pre-commit hook that runs &lt;code&gt;scan src/&lt;/code&gt; in a repository without &lt;code&gt;src&lt;/code&gt; passes every time, so make sure the paths in the hook exist.&lt;/p&gt;

&lt;p&gt;One more thing: &lt;code&gt;push&lt;/code&gt; is for text files.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frxir9yflswkgagdy7yw2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frxir9yflswkgagdy7yw2.png" alt="Terminal screen of pushing a binary file (recomposed): od -c cert.der shows 16 bytes starting 211 P N G; dotvault push cert.der prints &amp;quot;pushed cert.der (v1) → cert.der&amp;quot;; dotvault run -- sh -c 'od -c cert.der …' shows the first bytes changed to 357 277 275 P N G and 18 bytes; ls -la cert.der inside run shows 18 bytes." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first byte of the PNG header (0x89) came back as the three-byte Unicode replacement character. &lt;code&gt;push&lt;/code&gt; reads the file as a string, so a DER certificate cannot be distributed this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 6 covers one place for a value that several projects share, and the binding that decides which project sees it, in the console and with &lt;code&gt;dotvault global&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;dotvault stores the secrets in your .env and config files server-side and hands them to any authorised developer, CI job or AI agent with a single command (&lt;a href="https://dotvault.io" rel="noopener noreferrer"&gt;https://dotvault.io&lt;/a&gt;). It will be released free of charge when this series ends, and the steps in these articles are the steps you will use on day one.&lt;/p&gt;

&lt;p&gt;If you find this useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotvault</category>
      <category>secrets</category>
      <category>devops</category>
      <category>cli</category>
    </item>
    <item>
      <title>Overwrote a secret by mistake? Writing, pulling and rolling back keys</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Tue, 03 Nov 2026 05:04:54 +0000</pubDate>
      <link>https://dev.to/uehara/overwrote-a-secret-by-mistake-writing-pulling-and-rolling-back-keys-5874</link>
      <guid>https://dev.to/uehara/overwrote-a-secret-by-mistake-writing-pulling-and-rolling-back-keys-5874</guid>
      <description>&lt;p&gt;This is part 4 of the series "dotvault, Start to Finish: One Command from Your Laptop to CI, Your Team, and Your AI Agents." dotvault will be released free of charge when this series ends.&lt;/p&gt;

&lt;p&gt;Paste the staging &lt;code&gt;DATABASE_URL&lt;/code&gt; over the dev one by mistake, and with a plain &lt;code&gt;.env&lt;/code&gt; the old value is gone the moment you save. In dotvault it is not: one command, &lt;code&gt;dotvault rollback&lt;/code&gt;, brings it back. Writing a key never destroys the previous value. Each write is a new version: &lt;code&gt;DATABASE_URL&lt;/code&gt; v1, then v2. &lt;code&gt;dotvault versions DATABASE_URL&lt;/code&gt; lists them newest first with a timestamp, the writer's ID and a hash of the value. &lt;code&gt;dotvault rollback DATABASE_URL --to 1&lt;/code&gt; restores v1, and the restore itself is recorded as v3, so v2 stays in the history. Deleting a key removes its current value and keeps the history too. On top of that, keys can be written from the console (one at a time, or a whole &lt;code&gt;.env&lt;/code&gt; dropped on the Upload tab) or from the CLI (&lt;code&gt;set&lt;/code&gt; for one, &lt;code&gt;import&lt;/code&gt; for a file), and &lt;code&gt;dotvault pull --file .env.local&lt;/code&gt; fetches one file instead of all of them.&lt;/p&gt;

&lt;p&gt;This is part 4 of 12. Screens are from the production console and CLI 0.15.7, continuing with the &lt;code&gt;demo-app&lt;/code&gt; project created in part 2 and extended in part 3. One thing worth knowing up front: &lt;code&gt;dotvault versions FOO&lt;/code&gt; still shows v1 after &lt;code&gt;dotvault delete FOO&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keys can be written from the console or the CLI.&lt;/strong&gt; Console: + Add key on the Keys tab, or drop a &lt;code&gt;.env&lt;/code&gt; on the Upload tab. CLI: &lt;code&gt;dotvault set KEY=VALUE&lt;/code&gt; for one key, &lt;code&gt;dotvault import .env&lt;/code&gt; for a file. Prefer &lt;code&gt;import&lt;/code&gt; when there is more than one key; &lt;code&gt;set&lt;/code&gt; puts the value on the command line, where your shell history keeps it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A write is a new version, not an overwrite.&lt;/strong&gt; &lt;code&gt;dotvault versions KEY&lt;/code&gt; lists every version with its number, timestamp, writer and value hash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dotvault rollback KEY --to N&lt;/code&gt; restores version N as a new version.&lt;/strong&gt; Rolling back to v1 produces v3; v2 is not removed. History is never rewritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dotvault pull&lt;/code&gt; with no arguments writes every file the project has for the current environment.&lt;/strong&gt; &lt;code&gt;--file .env.local&lt;/code&gt; writes that one file only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reveal value shows a value in the console; Delete in the console or &lt;code&gt;dotvault delete&lt;/code&gt; removes a key.&lt;/strong&gt; The history stays visible through &lt;code&gt;versions&lt;/code&gt;. The Reveal button says "audit-logged"; the audit screen is covered in part 10.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you read on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where we start.&lt;/strong&gt; Part 3 left &lt;code&gt;demo-app&lt;/code&gt; with four environments (development, staging, production, preview) and a Common layer. You can follow this part without having read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment.&lt;/strong&gt; Production console, CLI 0.15.7, the same Node 20 Linux container as part 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal screens are recomposed.&lt;/strong&gt; The terminal output is reproduced verbatim in a cleaner frame. The raw output is kept as is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo account.&lt;/strong&gt; Project names, key names and values were created for this series. Every value is a placeholder made for this article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary.&lt;/strong&gt; "Secret" means any value that must not be exposed, such as an API key or a password. A "key" is a name such as &lt;code&gt;DATABASE_URL&lt;/code&gt;, the "value" is its content, and a "version" is the number that goes up by one every time the value is written.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Writing one key at a time in the console
&lt;/h2&gt;

&lt;p&gt;"+ Add key" on the Keys tab opens a name and a value field at the top of the list. Fill both in and press Add.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4y0imqaalyyd26ua1mvw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4y0imqaalyyd26ua1mvw.png" alt="demo-app Keys tab after pressing + Add key (English UI): a SESSION_SECRET row with a value field at the top of the list, Add and Cancel buttons. The Selected key panel on the right still shows DATABASE_URL with its value field and Save, Delete and Promote to Common buttons." width="800" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The list is sorted by name, not by the order you added keys. &lt;code&gt;SESSION_SECRET&lt;/code&gt; is deleted again later in this part.&lt;/p&gt;

&lt;p&gt;A project can hold more than one file. Besides &lt;code&gt;.env&lt;/code&gt;, you can add &lt;code&gt;.env.local&lt;/code&gt; or any other name. "+ Add file" opens a file name field, prefilled with &lt;code&gt;.env.local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc61w6tihdqbp63toils2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc61w6tihdqbp63toils2.png" alt="After pressing + Add file (English UI): &amp;quot;Create a new file:&amp;quot; followed by a field holding .env.local, with Create and Cancel buttons, above the existing env list of four keys." width="799" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create adds an &lt;code&gt;.env.local&lt;/code&gt; block under the &lt;code&gt;.env&lt;/code&gt; list and opens the first key row. The file is not saved until it has at least one key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjp89e7541xnxxlk9kred.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjp89e7541xnxxlk9kred.png" alt="Entering the first key of .env.local (English UI): env (4 keys) above, a new env.local (0 keys) block below with NEXT_PUBLIC_APP_URL and http://localhost:3000 being typed in." width="799" height="561"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After Add, the four keys of &lt;code&gt;.env&lt;/code&gt; and the one key of &lt;code&gt;.env.local&lt;/code&gt; sit in two separate blocks. Later in this part, the CLI pulls &lt;code&gt;.env.local&lt;/code&gt; alone, with &lt;code&gt;--file&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd4mals0j34hjy8ykyfgl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd4mals0j34hjy8ykyfgl.png" alt="demo-app Keys tab (English UI): the env block lists DATABASE_URL, FEATURE_FLAGS, REDIS_URL and SMTP_PASSWORD; the env.local block lists NEXT_PUBLIC_APP_URL. The Selected key panel is empty." width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Registering a whole .env from the console
&lt;/h2&gt;

&lt;p&gt;Ten keys, one at a time, is tedious. The Upload tab takes a &lt;code&gt;.env&lt;/code&gt; file and registers its keys in one go.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw6513en6d197qkanqbg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw6513en6d197qkanqbg.png" alt="Upload tab (English UI): a hint at the top reading &amp;quot;Parses keys (contents like a .env file) and registers them in bulk. Whole-file uploads go through the CLI's dotvault push (see the Files tab). Exceeding the plan limit (keys_per_file) returns a 429.&amp;quot;, the heading Bulk file upload, an environment switch with development selected and &amp;quot;Uploading into development.&amp;quot;, and a dashed drop zone reading &amp;quot;Drag .env, .npmrc, or .json files here — or click to browse&amp;quot;." width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pick the environment first; "Uploading into development." above the drop zone tells you where the file will land. Dropping the file shows a preview before anything is registered.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxss7pvfucdlxsw7mtxef.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxss7pvfucdlxsw7mtxef.png" alt="Preview after dropping a .env (English UI): &amp;quot;Preview — 2 key(s)&amp;quot; above a Key / Value (preview) table with MAIL_HOST = smtp.example.com and MAIL_FROM = noreply@example.com, a Target file field holding .env, and &amp;quot;Upload 2 key(s)&amp;quot; and Clear buttons." width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Check the rows, then press Upload. One line confirms the result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgogipcqttxqwe0pdosv3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgogipcqttxqwe0pdosv3.png" alt="Upload finished (English UI): a single line under the drop zone reading &amp;quot;Uploaded 2 key(s) to development/.env.&amp;quot;" width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Back on the Keys tab, the &lt;code&gt;.env&lt;/code&gt; list has six rows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76wzknshc49q7cfw83ae.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76wzknshc49q7cfw83ae.png" alt="Keys tab after the upload (English UI): the env block lists DATABASE_URL, FEATURE_FLAGS, MAIL_FROM, MAIL_HOST, REDIS_URL and SMTP_PASSWORD; the env.local block lists NEXT_PUBLIC_APP_URL." width="800" height="579"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The "Whole-file uploads" mentioned in the hint are a different mechanism for files such as &lt;code&gt;.npmrc&lt;/code&gt; or a kubeconfig that are distributed as they are; that is part 5. The Upload tab reads the keys and values out of the file and registers them one by one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Writing from the CLI: import and set
&lt;/h2&gt;

&lt;p&gt;Back in the terminal. The terminal screens from here to section 5 were captured before sections 1 and 2, so they show one file (&lt;code&gt;.env&lt;/code&gt;) and 5 keys: the four rows of the Keys tab plus &lt;code&gt;API_BASE_URL&lt;/code&gt; from Common. &lt;code&gt;MAIL_HOST&lt;/code&gt; and &lt;code&gt;MAIL_FROM&lt;/code&gt; from section 2 were deleted after the captures with &lt;code&gt;dotvault delete MAIL_HOST MAIL_FROM&lt;/code&gt; (not shown).&lt;/p&gt;

&lt;p&gt;There are two ways to write keys from the CLI. &lt;code&gt;dotvault import &amp;lt;file&amp;gt;&lt;/code&gt; registers every key/value pair in a file. &lt;code&gt;dotvault set KEY=VALUE&lt;/code&gt; registers the pairs given as arguments.&lt;/p&gt;

&lt;p&gt;Reach for &lt;code&gt;import&lt;/code&gt; first. &lt;code&gt;set&lt;/code&gt; takes the value on the command line, so the value ends up in your shell history (&lt;code&gt;~/.zsh_history&lt;/code&gt; and the like). &lt;code&gt;import&lt;/code&gt; reads a file; the only thing the history keeps is the file name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgt5gfngrkp1dygn1tha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgt5gfngrkp1dygn1tha.png" alt="dotvault import in the terminal (recomposed): cat .env.import shows a comment line, REDIS_URL and FEATURE_FLAGS; dotvault import .env.import prints &amp;quot;imported 2 keys into .env [demo-app/development]&amp;quot;; dotvault versions DATABASE_URL prints &amp;quot;1 versions&amp;quot; and a single v1 line." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;import&lt;/code&gt; reads only the &lt;code&gt;KEY=VALUE&lt;/code&gt; lines. Comment lines starting with &lt;code&gt;#&lt;/code&gt; are skipped. A quoted value (&lt;code&gt;FEATURE_FLAGS="beta,new-dashboard"&lt;/code&gt;) is stored without the quotes. A file with no &lt;code&gt;KEY=VALUE&lt;/code&gt; line at all stops with VALIDATION_ERROR (section 8).&lt;/p&gt;

&lt;p&gt;The third command in that screen lists the versions of &lt;code&gt;DATABASE_URL&lt;/code&gt;. It was entered from the console in part 2 and never touched since, so there is exactly one, v1. The next section overwrites it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Overwriting by mistake
&lt;/h2&gt;

&lt;p&gt;This is the accident from the opening, reproduced on purpose: &lt;code&gt;DATABASE_URL&lt;/code&gt; gets the name of the staging database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh0skrmd3tg8rkx9o0a99.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh0skrmd3tg8rkx9o0a99.png" alt="dotvault set overwriting a key (recomposed): dotvault set DATABASE_URL=…/app_staging prints &amp;quot;set DATABASE_URL (v2) in .env [demo-app/development]&amp;quot;; dotvault pull writes 5 keys, 1 file synced; grep DATABASE_URL .env shows the app_staging value; dotvault versions DATABASE_URL prints &amp;quot;2 versions&amp;quot; with v2 and v1 lines, each with a timestamp, a writer ID and a sha." width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set&lt;/code&gt; answers with &lt;code&gt;(v2)&lt;/code&gt;. Nothing was overwritten; a second version was added. &lt;code&gt;dotvault pull&lt;/code&gt; writes the v2 value (&lt;code&gt;app_staging&lt;/code&gt;) into &lt;code&gt;.env&lt;/code&gt;. One of the 5 keys is &lt;code&gt;API_BASE_URL&lt;/code&gt;, promoted to Common in part 3; Common keys are written on every pull, whichever environment you pull. So far this is what any text file would do.&lt;/p&gt;

&lt;p&gt;The difference is &lt;code&gt;dotvault versions DATABASE_URL&lt;/code&gt;. v2 and v1 are listed newest first, each with the timestamp, the ID of the person who wrote it and a hash of the value (&lt;code&gt;sha=&lt;/code&gt;). The value itself is not printed. Same hash, same value; different hash, different value. That is all it tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Rolling back
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dotvault rollback DATABASE_URL --to 1&lt;/code&gt; restores the v1 value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyz2e01pvof3cx63zofqw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyz2e01pvof3cx63zofqw.png" alt="dotvault rollback --to 1 in the terminal (recomposed): &amp;quot;rolled back DATABASE_URL to v1 (now v3) in .env [demo-app/development]&amp;quot;; dotvault versions DATABASE_URL prints &amp;quot;3 versions&amp;quot; with v3, v2 and v1, where v3 and v1 share the same sha; dotvault pull writes 5 keys; grep DATABASE_URL .env shows the app_dev value." width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output reads "rolled back DATABASE_URL to v1 (now v3)". You asked for v1, and the current version is v3. That is how dotvault rolls back: it does not delete v2 and step back to v1, it writes the v1 value again as v3. In &lt;code&gt;versions&lt;/code&gt;, v3 and v1 carry the same &lt;code&gt;sha=&lt;/code&gt; and only v2 differs. The mistaken v2 and the restoring v3 both stay.&lt;/p&gt;

&lt;p&gt;Getting the restored value onto disk is the usual &lt;code&gt;dotvault pull&lt;/code&gt;; &lt;code&gt;grep&lt;/code&gt; confirms &lt;code&gt;app_dev&lt;/code&gt; is back. A rollback changes the value on the dotvault side only. Your local &lt;code&gt;.env&lt;/code&gt; does not change until you pull.&lt;/p&gt;

&lt;p&gt;You can only roll back to a version that exists. The output for a version number that does not exist is in section 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Pulling: all files, or one
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dotvault pull&lt;/code&gt;, as used since part 2, writes every file the project holds for that environment when called without arguments. Section 1 added &lt;code&gt;.env.local&lt;/code&gt;, so there are now two. &lt;code&gt;--file&lt;/code&gt; restricts it to one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3i4pevsnxj1tols7d4ih.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3i4pevsnxj1tols7d4ih.png" alt="dotvault pull --file in the terminal (recomposed): dotvault pull --file .env.local prints &amp;quot;Wrote 1 keys to /work/demo-app/.env.local&amp;quot; and &amp;quot;1 file synced, 0 excluded, 1 key total&amp;quot;; cat .env.local shows NEXT_PUBLIC_APP_URL=http://localhost:3000 between BEGIN and END markers; dotvault pull prints one line for .env (5 keys) and one for .env.local (1 keys) and &amp;quot;2 files synced, 0 excluded, 6 keys total&amp;quot;; dotvault list demo-app shows development/.env and development/.env.local as separate rows." width="799" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.env.local&lt;/code&gt; is written the same way as &lt;code&gt;.env&lt;/code&gt;: wrapped in BEGIN / END markers, with &lt;code&gt;-rw-------&lt;/code&gt; permissions so only the owner can read it. In &lt;code&gt;dotvault list demo-app&lt;/code&gt;, development now has one row for &lt;code&gt;.env&lt;/code&gt; and one for &lt;code&gt;.env.local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; shows 5 keys here: the four rows of the Keys tab plus &lt;code&gt;API_BASE_URL&lt;/code&gt; from Common. &lt;code&gt;MAIL_HOST&lt;/code&gt; and &lt;code&gt;MAIL_FROM&lt;/code&gt; from section 2 were deleted after the captures, as noted at the start of section 3.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set&lt;/code&gt;, &lt;code&gt;versions&lt;/code&gt; and the other key commands also accept &lt;code&gt;--file&lt;/code&gt;. Without it they operate on the first entry of &lt;code&gt;files&lt;/code&gt; in &lt;code&gt;project-config.json&lt;/code&gt;, which is &lt;code&gt;.env&lt;/code&gt; after the &lt;code&gt;init&lt;/code&gt; from part 2. Working on a key in &lt;code&gt;.env.local&lt;/code&gt; means adding &lt;code&gt;--file .env.local&lt;/code&gt; every time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftnagxaf12c00fi353in1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftnagxaf12c00fi353in1.png" alt="--file changing which file a command looks at (recomposed): dotvault versions NEXT_PUBLIC_APP_URL prints &amp;quot;[demo-app/development/.env] — 0 versions&amp;quot;; with --file .env.local it prints &amp;quot;[demo-app/development/.env.local] — 1 versions&amp;quot; and a v1 line; dotvault set --file .env.local NEXT_PUBLIC_APP_URL=http://localhost:3001 prints (v2); dotvault rollback NEXT_PUBLIC_APP_URL --file .env.local --to 1 prints &amp;quot;to v1 (now v3)&amp;quot;." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Forget &lt;code&gt;--file&lt;/code&gt; and the command looks in &lt;code&gt;.env&lt;/code&gt;, reports "0 versions" and exits without an error. When a key seems to be missing, check the file first.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Revealing, describing and deleting
&lt;/h2&gt;

&lt;p&gt;Selecting a key in the console opens the Selected key panel on the right, with a description field and a "Reveal value (audit-logged)" button. Reveal value turns the button into a field holding the value, with a Save button. Save was not pressed in this part.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zxkpp1l4cign67jsvwi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zxkpp1l4cign67jsvwi.png" alt="Keys tab with DATABASE_URL selected and revealed (English UI): env (4 keys) and env.local (1 keys) on the left; the Selected key panel shows development/env/DATABASE_URL, a Description field reading &amp;quot;RDS writer (app_dev). Rotated quarterly.&amp;quot;, a value field reading postgres://app:demo-pass@db.internal:5432/app_dev, and Save, Delete and Promote to Common (_shared) buttons." width="800" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The button says "audit-logged". The audit screen is part 10; who else on a team can reveal a value is part 9, where people are invited.&lt;/p&gt;

&lt;p&gt;The description is a short text kept next to the value. The console has a Save description button; in this part it was set from the CLI with &lt;code&gt;dotvault describe KEY "text"&lt;/code&gt;. Changing the description does not add a version.&lt;/p&gt;

&lt;p&gt;Deleting a key is Delete in the console or &lt;code&gt;dotvault delete KEY&lt;/code&gt; on the CLI. The console asks first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvxvs07icr80ol7ptqe9l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvxvs07icr80ol7ptqe9l.png" alt="Delete confirmation (English UI): one line reading &amp;quot;Delete development/env/SESSION_SECRET? This cannot be undone.&amp;quot; with Cancel and Delete buttons." width="799" height="159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The CLI does not ask, and takes several keys at once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4b1960k4njbhnz86epgf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4b1960k4njbhnz86epgf.png" alt="dotvault describe and dotvault delete in the terminal (recomposed): describe DATABASE_URL &amp;quot;RDS writer (app_dev). Rotated quarterly.&amp;quot; prints &amp;quot;described DATABASE_URL in .env [demo-app/development]&amp;quot;; describe NO_SUCH_KEY &amp;quot;x&amp;quot; prints a yellow &amp;quot;warning: NO_SUCH_KEY has no current value … — description not applied&amp;quot; and echo $? prints 1; set FOO=1 BAR=2 prints two (v1) lines; delete FOO BAR prints &amp;quot;deleted 2 keys from .env [demo-app/development]&amp;quot;; versions FOO prints &amp;quot;1 versions&amp;quot; and a v1 line." width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the last command, &lt;code&gt;dotvault versions FOO&lt;/code&gt;. The key was just deleted, and v1 is still listed. Delete removes the current value; it does not remove the history.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;describe&lt;/code&gt; on a key that does not exist prints a warning and exits with code 1. The exit code is the number a command returns when it finishes: 0 means success, anything else means failure. Type &lt;code&gt;echo $?&lt;/code&gt; right after the command to see it. It is a yellow warning rather than a red &lt;code&gt;✖&lt;/code&gt;, so a script should check the exit code rather than the colour.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. When it fails
&lt;/h2&gt;

&lt;p&gt;The failures met in this part, with their exit codes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzrolb4cqniknr9m66i3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzrolb4cqniknr9m66i3.png" alt="Four failures in the terminal (recomposed): rollback --to 9 prints PROJECT_NOT_FOUND (server: version 9 of 'DATABASE_URL' not found) and exit 5; rollback --to 0 prints VALIDATION_ERROR (--to must be a positive integer version) and exit 6; import empty.env prints VALIDATION_ERROR (No KEY=VALUE lines found) and exit 6; pull --file .env.nope prints PROJECT_NOT_FOUND (server: No DDB secrets for .env.nope) and exit 5." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rollback --to 9&lt;/code&gt; (no such version)&lt;/td&gt;
&lt;td&gt;PROJECT_NOT_FOUND, server text "version 9 … not found"&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rollback --to 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;VALIDATION_ERROR (positive integer)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;import empty.env&lt;/code&gt; (no KEY=VALUE line)&lt;/td&gt;
&lt;td&gt;VALIDATION_ERROR (No KEY=VALUE lines)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pull --file .env.nope&lt;/code&gt; (no such file)&lt;/td&gt;
&lt;td&gt;PROJECT_NOT_FOUND, server text "No DDB secrets for .env.nope"&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;describe NO_SUCH_KEY&lt;/code&gt; (no such key)&lt;/td&gt;
&lt;td&gt;warning (description not applied)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A missing version and a missing file both come back as PROJECT_NOT_FOUND with exit code 5. What was not found is named in the server text in parentheses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 5 is "Still sending .npmrc over Slack? Push whole files, scan before you commit": &lt;code&gt;dotvault push&lt;/code&gt; for files that do not split into keys and values, and &lt;code&gt;dotvault scan&lt;/code&gt; for catching secrets before they are committed, with screens.&lt;/p&gt;




&lt;p&gt;dotvault stores the secrets in your .env and config files server-side and hands them to any authorised developer, CI job or AI agent with a single command (&lt;a href="https://dotvault.io" rel="noopener noreferrer"&gt;https://dotvault.io&lt;/a&gt;). It will be released free of charge when this series ends, and the steps in these articles are the steps you will use on day one.&lt;/p&gt;

&lt;p&gt;If you find this useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotvault</category>
      <category>secrets</category>
      <category>devops</category>
      <category>cli</category>
    </item>
    <item>
      <title>Checking the code you wrote: static analysis</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Mon, 02 Nov 2026 05:49:34 +0000</pubDate>
      <link>https://dev.to/uehara/checking-the-code-you-wrote-static-analysis-2g33</link>
      <guid>https://dev.to/uehara/checking-the-code-you-wrote-static-analysis-2g33</guid>
      <description>&lt;p&gt;This is part 6 of the series "Protect Your Own Service Yourself — Self-Defense Security in the Age of AI." Do the checks you can yourself; when it's hard, EarthLink Network can run the assessment for you, and fix the gaps you can't fix on your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Series contents&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/anyone-can-attack-now-with-ai-check-your-own-site-first-3o1h"&gt;Anyone can attack now, with AI — check your own site first&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/what-attackers-now-hold-when-ai-runs-the-attack-itself-56dk"&gt;What attackers now hold: when AI runs the attack itself&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/before-you-start-the-whole-picture-of-checking-and-how-to-test-without-breaking-production-24if"&gt;Before you start: the whole picture of checking, and how to test without breaking production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/testing-from-the-outside-1-setup-and-the-first-run-2o76"&gt;Testing from the outside (1): setup and the first run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/testing-from-the-outside-2-attack-tools-and-legitimate-scanners-find-different-things-20i0"&gt;Testing from the outside (2): attack tools and legitimate scanners find different things&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Checking the code you wrote: static analysis (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Checking the parts you depend on: libraries and packages (coming soon)&lt;/li&gt;
&lt;li&gt;Checking your infrastructure settings for gaps (coming soon)&lt;/li&gt;
&lt;li&gt;Collect every result in one place and decide what to fix first (coming soon)&lt;/li&gt;
&lt;li&gt;How to fix what you found: three ways (coming soon)&lt;/li&gt;
&lt;li&gt;When you cannot fix it yourself: a maintenance contract (coming soon)&lt;/li&gt;
&lt;li&gt;Keep checking automatically every day — from building to protecting (coming soon)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;We scanned our own service's code with a free static analyzer and its security rule set. Zero findings.&lt;/p&gt;

&lt;p&gt;Then we ran the same code through a different rule set, the JavaScript one. One finding: the line that decides where to send a user after login.&lt;/p&gt;

&lt;p&gt;And the flaw behind that line, a check that let &lt;code&gt;/\&lt;/code&gt; slip through, was not caught by any rule in the Semgrep rule sets we ran. Our AI code reviewer, CodeRabbit, caught it when we ran it on the fix. (It has been fixed.)&lt;/p&gt;

&lt;p&gt;That is the whole lesson of this part. &lt;strong&gt;Zero findings does not mean safe.&lt;/strong&gt; Different ways of checking find different weaknesses. Layer them, check every hit for false positives, and run the checks again on a schedule.&lt;/p&gt;

&lt;p&gt;Static analysis has a second job, too: finding secrets, like API keys and passwords, written into the code. When we searched our git history with Gitleaks, an AWS access key committed earlier was still there, even though it had been deleted from the file. It turned out to be deactivated already. Finding it is covered here. Revoking it and scrubbing the history properly is Part 10.&lt;/p&gt;

&lt;p&gt;The rest of this part has three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scan.&lt;/strong&gt; Three ways to check code, what happened on our own service, and how to search for leftover secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort out false positives.&lt;/strong&gt; Where to look, and what to look at, to tell a weakness from noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it again on a schedule.&lt;/strong&gt; So one zero is never the end of it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. Scan
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What static analysis is
&lt;/h3&gt;

&lt;p&gt;Static analysis reads your source files one by one and flags code written in a risky way. For each hit you get the file, the line, and an explanation.&lt;/p&gt;

&lt;p&gt;Parts 4 and 5 compared testing a running service from the outside with an AI tool that reads the code (Strix). This part is about code-reading checks themselves. Compared with outside testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It checks every file by the same standard.&lt;/strong&gt; Outside testing only reaches the entry points the world can see. Reading the code also covers features not yet on screen and admin-only features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is cheap and fast.&lt;/strong&gt; Many tools are free, and thousands of files take minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot see what only shows up at runtime.&lt;/strong&gt; Whether the server sends the right security headers, for example, is a job for outside testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You need both. This part covers the code side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three ways to check
&lt;/h3&gt;

&lt;p&gt;Static analysis tools fall into three groups, by how they find weaknesses. This matters more than the brand names, because a different method finds different things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match against rules.&lt;/strong&gt; The tool carries hundreds of rules ("this pattern is risky") and compares every file against them. A bundle of rules is called a rule set. Great at sweeping for known bad patterns; blind to anything no rule describes. Free options: Semgrep Community Edition and SonarQube Community Build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow the data.&lt;/strong&gt; The tool tracks a value from where it enters, across files, to where it is used. That catches things like "a user-controlled value reaches a database query unchecked," with fewer false alarms than rules alone. Free options: Semgrep's free plan (with an account) and GitHub CodeQL for public repositories. SonarQube has this too, but only on paid plans (Team or higher on SonarQube Cloud).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let an AI read it.&lt;/strong&gt; An AI reviews the code the way a colleague would, and can notice reasoning errors no rule captures, such as "this check isn't enough." Strix, from Parts 4 and 5, is this kind of tool. For everyday reviews we use CodeRabbit. It reviews every pull request automatically and comments on the lines it is worried about. You can also run it from the command line on work in progress.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them have a free starting point. The table near the end has the details.&lt;/p&gt;

&lt;h3&gt;
  
  
  Same code, two rule sets
&lt;/h3&gt;

&lt;p&gt;Here is what happened on promptflow, our own service. We scanned a copy of the code from before this weakness was fixed. No production system was touched.&lt;/p&gt;

&lt;p&gt;Semgrep installs with one line, &lt;code&gt;python3 -m pip install semgrep&lt;/code&gt;. We pointed it at the app's source folder with the security rule set, &lt;code&gt;p/security-audit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ec8h5zkfedyxe1dodpa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ec8h5zkfedyxe1dodpa.png" alt="Semgrep scanning the promptflow app code with the security rule set. Command: &amp;quot;semgrep scan --config p/security-audit --metrics=off --json --output …/semgrep-security-audit-src.json src/&amp;quot;. The Scan Summary shows &amp;quot;Findings: 0 (0 blocking)&amp;quot;, &amp;quot;Rules run: 23&amp;quot;, &amp;quot;Targets scanned: 2016&amp;quot;, with the line &amp;quot;Ran 23 rules on 2016 files: 0 findings.&amp;quot; below it. The output folder is shortened to &amp;quot;…/&amp;quot;." width="800" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;2,016 files in about two minutes. Zero findings.&lt;/p&gt;

&lt;p&gt;Then the same code with the JavaScript rule set, &lt;code&gt;p/javascript&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1agx09ghklvyu43w7pp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1agx09ghklvyu43w7pp.png" alt="The same code scanned with the JavaScript rule set. Command: &amp;quot;semgrep scan --config p/javascript --metrics=off --json --output …/semgrep-javascript-src.json src/&amp;quot;. &amp;quot;Findings: 1 (1 blocking)&amp;quot;, &amp;quot;Ran 74 rules on 1730 files: 1 finding.&amp;quot; Below, the single finding pulled out of the result file with jq: check_id javascript.browser.security.open-redirect.js-open-redirect, at line 53 of src/app/auth/callback/page.tsx, severity WARNING. The message warns that letting a user-controlled value decide where the page navigates can send users to another site or run malicious code." width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One finding. The flagged line decides where to send the user after login. In plain words, Semgrep is saying: "The destination comes from a value a user might control. If an attacker can set it, they can bounce your users to a fake site. Check it before you use it." This is called an open redirect, and it is a classic way to lure people onto a fake login page.&lt;/p&gt;

&lt;p&gt;That rule simply is not in the security rule set. Same method (rule matching), same code; switching rule sets changed what was found.&lt;/p&gt;

&lt;h3&gt;
  
  
  A different method finds what no rule covers
&lt;/h3&gt;

&lt;p&gt;Is the line dangerous? Following the code, the destination is checked first, in a function in another file. The check keeps the destination on the same site (paths like &lt;code&gt;/settings&lt;/code&gt;) and replaces anything starting with &lt;code&gt;https://&lt;/code&gt; or &lt;code&gt;//&lt;/code&gt; with the home page.&lt;/p&gt;

&lt;p&gt;But the check had a flaw. It blocked &lt;code&gt;//&lt;/code&gt;, yet let &lt;code&gt;/\&lt;/code&gt; (a slash followed by a backslash) through. Browsers sometimes treat a backslash in a URL like a forward slash, so &lt;code&gt;/\&lt;/code&gt; can be read the same way as &lt;code&gt;//&lt;/code&gt;: go to another site. Get someone to click a crafted link and log in, and the code allowed sending them elsewhere afterwards.&lt;/p&gt;

&lt;p&gt;Semgrep did not find that flaw. While fixing the line, we ran CodeRabbit's command-line review on the change, and it pointed out &lt;code&gt;/\&lt;/code&gt;. None of the Semgrep rule sets we ran describes that pattern. Rule matching and AI review find different things. After that review, we changed the check so values starting with &lt;code&gt;/\&lt;/code&gt; also go to the home page.&lt;/p&gt;

&lt;p&gt;The step-by-step trace through the code is in the appendix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why zero is not safe
&lt;/h3&gt;

&lt;p&gt;The first scan's zero only meant "no code matched these rules." Three reasons it is not a clean bill of health:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rules are limited.&lt;/strong&gt; Only 23 rules ran. That is not every risky pattern in the world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The free engine stays inside one function.&lt;/strong&gt; Per Semgrep's docs, the account-free Community Edition does not follow a value from one file to another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some files were cut short.&lt;/strong&gt; The JSON results recorded timeout warnings on 22 files. The on-screen summary still said "Scan completed successfully," so you only notice if you open the result file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you layer methods: add rule sets, add a tool that follows the data, add AI review. One method's zero can be another method's finding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets left in code, including git history
&lt;/h3&gt;

&lt;p&gt;The second job of static analysis is finding secrets, like API keys and passwords, written directly into code.&lt;/p&gt;

&lt;p&gt;This is a different kind of risk. Everything above was about whether the running site can be broken into from the outside. A leftover secret never appears in any response. You could send a million requests and never see it, and on its own it does not let anyone in from the outside.&lt;/p&gt;

&lt;p&gt;The danger starts when the repository itself ends up in someone else's hands. Say a GitHub account or a developer's laptop gets compromised. Any key in the code can then be used to run up your cloud bill or pull your data.&lt;/p&gt;

&lt;p&gt;And here is the part people miss: git keeps every past change. If you commit a key and delete it in the next commit, the old commit still has it. Anyone with the repository can pull it back out. That is why you search the history, not just the current files.&lt;/p&gt;

&lt;p&gt;Gitleaks is a single binary from its GitHub releases page. We scanned the full history of the promptflow repository.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27eoo2iv5yidyurljl48.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27eoo2iv5yidyurljl48.png" alt="Gitleaks scanning every past commit of the promptflow repository. Command: &amp;quot;gitleaks git . --report-format json --report-path …/gitleaks-git.json --redact&amp;quot;. Output: &amp;quot;7633 commits scanned.&amp;quot;, &amp;quot;scanned ~330617031 bytes (330.62 MB) in 1m26s&amp;quot;, &amp;quot;WRN leaks found: 2&amp;quot;. Below, the two findings pulled out with jq: RuleID aws-access-token and generic-api-key, both in scripts/local/compare-rag-e2e.mts at lines 165 and 166. Secret shows REDACTED." width="799" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;7,633 commits in 1 minute 26 seconds. Two hits, on two adjacent lines of the same file: one shaped like an AWS access key ID, the other like its matching secret.&lt;/p&gt;

&lt;p&gt;Always pass &lt;code&gt;--redact&lt;/code&gt;. Without it, the secret is printed to your terminal and saved in the report file, and the moment you share that file you have leaked the key a second time.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Sort out false positives
&lt;/h2&gt;

&lt;p&gt;Rule-based tools flag things "just in case." Not every hit is a weakness, so check each one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code findings
&lt;/h3&gt;

&lt;p&gt;Start at the flagged line and trace where the value comes from, and whether it is checked before use.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What tracing shows&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Next step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The value never comes from outside&lt;/td&gt;
&lt;td&gt;False positive&lt;/td&gt;
&lt;td&gt;Note why, mark it as a false positive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It comes from outside but is checked before use&lt;/td&gt;
&lt;td&gt;False positive (the tool can't see the check)&lt;/td&gt;
&lt;td&gt;Note where the check is, mark it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It is checked, but the check falls short&lt;/td&gt;
&lt;td&gt;Weakness (minor)&lt;/td&gt;
&lt;td&gt;Fix the check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It comes from outside and is never checked&lt;/td&gt;
&lt;td&gt;Weakness&lt;/td&gt;
&lt;td&gt;Fix first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our Semgrep finding looked like row 2 at first and turned out to be row 3. The tool's output alone cannot tell you which row you are in. Only a person or an AI tracing the code can.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leftover secrets
&lt;/h3&gt;

&lt;p&gt;For a leaked secret, check three things: is it a documentation sample, is it a made-up test value, and does it still work. The report hides the value, so we opened the flagged commit locally. (The value appears nowhere in this article or in our notes.)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Neither a sample nor a test value.&lt;/strong&gt; AWS access key IDs are 20 characters starting with a fixed four-letter prefix. The line matched, and it was not the documentation sample that ends in &lt;code&gt;EXAMPLE&lt;/code&gt;. A key had been pasted straight into a small local test script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleted from the file, still in git history.&lt;/strong&gt; The current files no longer contain the key. The old commit still had it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already dead.&lt;/strong&gt; A read-only AWS API call showed the key as Inactive, with a newer key issued for the same user. It had been replaced and switched off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you find a leaked key, the first question is always "does it still work?" How to revoke it and how to scrub git history properly are Part 10.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mark false positives so they stop coming back
&lt;/h3&gt;

&lt;p&gt;Once you know a hit is a false positive, mark it, or the same noise comes back every run and buries the new findings.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semgrep:&lt;/strong&gt; add &lt;code&gt;// nosemgrep: &amp;lt;rule id&amp;gt;&lt;/code&gt; on the flagged line, with your reason next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gitleaks:&lt;/strong&gt; add &lt;code&gt;gitleaks:allow&lt;/code&gt; on the line for a test value, or put the finding's fingerprint (an ID string Gitleaks assigns to each finding) in a &lt;code&gt;.gitleaksignore&lt;/code&gt; file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Always write the reason. A mark without a reason can just as easily hide a weakness. And keep each exclusion to a single line: excluding whole test folders means a key pasted there will be missed too.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Run it again on a schedule
&lt;/h2&gt;

&lt;p&gt;A zero today is a zero for today's code with today's method.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The code changes every day.&lt;/strong&gt; New code can bring new weaknesses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rules grow.&lt;/strong&gt; When a new attack technique becomes known, rules to catch it get added. Yesterday's clean code can fail today's rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets slip in at any time.&lt;/strong&gt; A key pasted in "just to test" and committed by mistake happens to everyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So turn it into a routine. We run the checks at three points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On every pull request.&lt;/strong&gt; Semgrep with the JavaScript, Python, and security rule sets, plus Gitleaks and Trivy, which checks dependencies (Part 7). CodeRabbit reviews the code too. New high-severity findings block the merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right after merging.&lt;/strong&gt; Semgrep, Gitleaks, and Trivy run again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every day.&lt;/strong&gt; GitHub Actions runs Semgrep, Gitleaks, and Trivy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody does anything by hand each morning. Set it up once and the machine checks every day. Results go to the collection point in Part 9, and Part 12 shows the daily runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is free
&lt;/h2&gt;

&lt;p&gt;Everything here has a free starting point. Prices are from each vendor's official pricing page.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Free tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Semgrep Community Edition&lt;/td&gt;
&lt;td&gt;Rules&lt;/td&gt;
&lt;td&gt;Unlimited (open source)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semgrep free plan&lt;/td&gt;
&lt;td&gt;Rules + data flow&lt;/td&gt;
&lt;td&gt;Up to 10 repositories and 10 contributors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SonarQube Community Build&lt;/td&gt;
&lt;td&gt;Rules&lt;/td&gt;
&lt;td&gt;Unlimited if self-hosted; data-flow detection needs a paid edition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SonarQube Cloud Free&lt;/td&gt;
&lt;td&gt;Rules (data flow needs Team)&lt;/td&gt;
&lt;td&gt;Private code up to 50,000 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub CodeQL&lt;/td&gt;
&lt;td&gt;Data flow&lt;/td&gt;
&lt;td&gt;Public repositories only; private needs a GitHub Team plan or higher plus $30 per active committer per month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeRabbit&lt;/td&gt;
&lt;td&gt;AI review&lt;/td&gt;
&lt;td&gt;Public repositories get full pull request reviews free; private repositories get pull request summaries plus IDE/CLI reviews (3 per hour per user)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gitleaks&lt;/td&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;Unlimited (open source)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our repositories are private, so we pay for CodeRabbit's pull request reviews, and we use neither CodeQL nor GitHub's secret scanning ($19 per committer per month on private repositories). We run free Semgrep and Gitleaks every day instead. Part 3 compares pricing in more detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 7 checks the parts you depend on. The code you write is a small share of what you ship; most of it is third-party packages. We will check those packages for known vulnerabilities, with commands and screens again. This is the check that usually turns up the most findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tracing the Semgrep finding through the code
&lt;/h3&gt;

&lt;p&gt;The flagged line 53, next to where its value comes from:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3i46clqumw53yx144izq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3i46clqumw53yx144izq.png" alt="The flagged line and where its value comes from. Top: lines 41–55 of src/app/auth/callback/page.tsx, with line 53, &amp;quot;window.location.href = result.returnTo;&amp;quot;, highlighted. Bottom: another file, shared/src/services/auth.ts, lines 392–397 (sanitizeReturnTo, which replaces values that do not start with &amp;quot;/&amp;quot; or that start with &amp;quot;//&amp;quot; with &amp;quot;/&amp;quot;) and lines 549–567 (resolveSilentSsoCallbackError; line 557 does nothing unless the incoming value matches the one the browser saved, and line 565 passes the destination through sanitizeReturnTo before returning it)." width="800" height="813"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Line 53 navigates to &lt;code&gt;result.returnTo&lt;/code&gt;, the post-login destination. This is what Semgrep flagged.&lt;/li&gt;
&lt;li&gt;That value is built by a function in another file, &lt;code&gt;resolveSilentSsoCallbackError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;At line 557, that function compares the &lt;code&gt;state&lt;/code&gt; value in the URL with the one this browser saved when it started the login. No match, no action. An attacker cannot inject a value this way.&lt;/li&gt;
&lt;li&gt;At line 565, the destination goes through &lt;code&gt;sanitizeReturnTo&lt;/code&gt;, which keeps it to a path on the same site and replaces anything starting with &lt;code&gt;https://&lt;/code&gt; or &lt;code&gt;//&lt;/code&gt; with &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the check lives in another file, the free Semgrep engine, which does not follow values across files, cannot see it.&lt;/p&gt;

&lt;p&gt;But the check in step 3 only stops someone swapping in their own &lt;code&gt;state&lt;/code&gt;. The destination can be set earlier, by another route:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The login entry page, &lt;code&gt;/sso&lt;/code&gt;, accepts a &lt;code&gt;returnTo&lt;/code&gt; value in the URL as the post-login destination.&lt;/li&gt;
&lt;li&gt;That value goes through &lt;code&gt;sanitizeReturnTo&lt;/code&gt;. Anything starting with &lt;code&gt;/\&lt;/code&gt; slips through.&lt;/li&gt;
&lt;li&gt;The user's own browser saves it into &lt;code&gt;state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;After login, &lt;code&gt;state&lt;/code&gt; matches, because the browser saved it itself, and the page navigates to the &lt;code&gt;/\&lt;/code&gt; destination.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That final navigation happens at line 33 of the same file, not the flagged line 53. Semgrep flagged only one of the two places that can send the user to another site.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the commands mean
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;semgrep scan --config &amp;lt;rule set&amp;gt;&lt;/code&gt;: which rule set to use. &lt;code&gt;p/security-audit&lt;/code&gt; and &lt;code&gt;p/javascript&lt;/code&gt; are fetched automatically from Semgrep's public registry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--metrics=off&lt;/code&gt;: do not send usage statistics to Semgrep.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--json --output &amp;lt;file&amp;gt;&lt;/code&gt;: save machine-readable results. Things the screen does not show, like timeout warnings, are kept here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gitleaks git .&lt;/code&gt;: scan the full history of the current repository. Use &lt;code&gt;gitleaks dir&lt;/code&gt; to scan only the current files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--redact&lt;/code&gt;: replace found values with &lt;code&gt;REDACTED&lt;/code&gt;, on screen and in the report.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;In this series, in an age when attack tools are free and one click away for anyone, I show step by step how to check your own service yourself and close the gaps before someone else finds them. The target is always our own service; the series does not cover attacking anyone else's site.&lt;br&gt;
If running the checks yourself is hard, EarthLink Network can do the assessment for you. Found a gap you can't fix? We'll handle the fix. Feel free to reach out.&lt;br&gt;
If this was useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>staticanalysis</category>
      <category>devsecops</category>
      <category>infosec</category>
    </item>
    <item>
      <title>Adding RAG: store your own judgment and grow your AI</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Sat, 31 Oct 2026 05:00:38 +0000</pubDate>
      <link>https://dev.to/uehara/adding-rag-store-your-own-judgment-and-grow-your-ai-3182</link>
      <guid>https://dev.to/uehara/adding-rag-store-your-own-judgment-and-grow-your-ai-3182</guid>
      <description>&lt;p&gt;This is part 1 of the series "Adopting RAG — Grow your AI on your own accumulated judgment."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Series contents&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Adding RAG: store your own judgment and grow your AI (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;What RAG should search, and what an index should answer (coming soon)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My Claude Code setup loaded a 32KB index of its own notes at the start of every session, and it still kept making the mistakes those notes were written to prevent. "Take the screenshots for the English version in the English UI." "This article is too long." Each correction was saved as a memory note the first time I made it. One of those notes still quotes me asking how many times I have to say it.&lt;/p&gt;

&lt;p&gt;The notes were there. What was missing was the moment. When the situation came up mid-task, the AI did not go back and look. That is not specific to Claude Code: any AI coding agent with saved notes, Codex included, has to decide to search them, and that decision is the step that gets skipped.&lt;/p&gt;

&lt;p&gt;So I added RAG. RAG (retrieval-augmented generation) means that before the AI answers, something searches your own records and hands it the closest ones. Mine runs on every message I send: it searches my past notes and conversations by meaning and hands the closest few to the AI before it answers. We call it memory recall. It is part of ELN workflow, the in-house plugin suite we use with Claude Code. It relies on a Claude Code hook (a script Claude Code runs automatically at fixed moments, here every time you send a message), so today it runs on Claude Code only. The idea would work for any agent.&lt;/p&gt;

&lt;p&gt;Nothing gets retrained. What changes is what the AI is handed. Every decision and correction I write down becomes something it can be shown the next time the situation comes up, so the more of my own judgment I store, the more the AI has to work with. That is what I mean by growing the AI with RAG.&lt;/p&gt;

&lt;p&gt;An earlier article covered a related hook that hands the AI a list of our architecture decisions once, at session start (&lt;a href="https://dev.to/uehara/the-real-reason-ai-ignores-existing-decisions-designing-an-adr-context-auto-injection-hook-34e2"&gt;The real reason AI ignores existing decisions&lt;/a&gt;). Resending the same list on every message was wasteful, so that hook moved to session start. This one works per message: what it hands over changes with what you just said, it hands over nothing or a short list when the match is weak, and it never exceeds 2,048 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every message triggers a RAG search.&lt;/strong&gt; It looks through the current project's memory notes, the titles of past conversations in the same project, and our shared engineering rules, and hands the closest matches to the AI before it answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting it up takes five steps.&lt;/strong&gt; ELN workflow is in-house and not published, but every part it uses is public. The details, and a prompt you can hand to Claude Code as is, are in "Setting it up yourself" near the end.

&lt;ol&gt;
&lt;li&gt;Write each correction or decision down as its own memory note, one note per file.&lt;/li&gt;
&lt;li&gt;Turn the notes into embeddings with bge-m3 and store them in a local index.&lt;/li&gt;
&lt;li&gt;Keep a search process running with the model loaded, embed each message with the same model, and rank the index by closeness.&lt;/li&gt;
&lt;li&gt;Query it from the Claude Code hook that runs on every message (UserPromptSubmit), and hand the headlines it returns to the AI.&lt;/li&gt;
&lt;li&gt;Decide how much to hand over by how far the top hit stands out from the rest.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored judgment keeps paying off.&lt;/strong&gt; A correction written down once becomes a candidate whenever a related message comes in, and is handed over when it is close enough. The model stays the same; the records it gets keep growing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It matches meaning, not words.&lt;/strong&gt; A question with no words in common with the note still finds it. Because the model is multilingual, an English question even finds a note written in Japanese.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It hands over headlines, not documents.&lt;/strong&gt; At most five items and 2,048 bytes per message. Conversations go in as date and title only, never their content. The block is labeled as reference, not instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It injects nothing or only a short list when the match is weak.&lt;/strong&gt; Similarity scores alone could not tell related messages from unrelated ones, so it looks at how far the top hit stands out from the rest, and injects nothing, a little, or a bit more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The search runs on the Mac.&lt;/strong&gt; The embedding model (which turns text into numbers that can be compared by meaning), the index and the search process all live locally. The search sends nothing to a paid embedding API, and it costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It never holds up the conversation.&lt;/strong&gt; If the search does not answer within 300 ms, the message goes through without it. Sixty repeat queries had a median of 69 ms and a worst case of 228 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The screens below are real output from the live index on my Mac. The tool's own injection text is in Japanese only, so for this version I asked English questions and used &lt;code&gt;jq&lt;/code&gt; (a command-line JSON filter) to print just the fields that matter. The command lines drop the script's install path, and the home folder in the output is shown as ~/.&lt;/p&gt;

&lt;h2&gt;
  
  
  An English question, a Japanese note
&lt;/h2&gt;

&lt;p&gt;Here is the idea in one screen. I ask, in English, when the firm was founded. Searching the notes folder for "firm" or "founded" as whole words finds nothing. Searching by meaning puts the note about my name and company first, well ahead of the next one. That note records the founding year in Japanese, as 創業 2000年 ("founded in 2000").&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fky5lgc4fsd5d3qb2m0ry.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fky5lgc4fsd5d3qb2m0ry.png" alt="Terminal, real output. First, grep -rilw -e firm -e founded over the blog project's memory folder, piped to wc -l, prints 0. Then recall.py query &amp;quot;When was the firm founded?&amp;quot; -k 20 --json, filtered with jq to the top three memory notes, prints a score and a note name on each line: 0.488 author-identity, 0.394 profile-bio, 0.387 blog-company-slug." width="799" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Neither word from the question appears anywhere in that note. The note does contain some English, such as the company name and the file name, but the part about the founding year is written only in Japanese. A text search can only find the exact characters. The search here turns both the question and every note into embeddings (lists of numbers arranged so that texts with similar meaning end up close together) and compares those. The model is bge-m3, published by BAAI, which handles many languages, including English and Japanese, in the same space.&lt;/p&gt;

&lt;p&gt;Long notes are split into 700-character pieces, and a note scores by its best piece, so that a detail further down than the first few lines still has a chance to be found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scores alone could not separate related from unrelated
&lt;/h2&gt;

&lt;p&gt;Handing notes to the AI on every message is only useful if it holds back when nothing is relevant. Otherwise the context fills with noise. This was the part that surprised me.&lt;/p&gt;

&lt;p&gt;In testing, the highest-scoring unrelated question scored 0.597. The median question that did have a correct note scored 0.589. Any cutoff on the score would either inject notes into unrelated messages or miss real ones.&lt;/p&gt;

&lt;p&gt;What did separate them was the shape of the results. When a message really matches a note, the top hit stands out. When it matches nothing, everything scores about the same. So the rule looks at the gap between the top score and the average of ranks 2 to 11, measured over notes and shared rules only (past conversations shrink the gap). Across the unrelated questions that gap was at most 0.070. For questions with a correct note, the median gap was 0.080 to 0.096.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gap between the top hit and the rest&lt;/th&gt;
&lt;th&gt;What gets injected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Below 0.025, or a top score below 0.5&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.025 to 0.11&lt;/td&gt;
&lt;td&gt;Up to 3 notes or rules, 1,024 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.11 and above&lt;/td&gt;
&lt;td&gt;Up to 3 notes or rules close to the top, plus up to 2 past conversations only when one clearly stands out among them (5 items, 2,048 bytes)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Either way, a note from the current project that scores close to the top goes in first, so a similar shared rule cannot crowd it out. Messages shorter than 8 characters, like "go on", are not searched at all.&lt;/p&gt;

&lt;p&gt;Here is that selection applied to two messages: one asking why a Facebook page post did not show up, which my notes cover, and one about dinner.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya4agh64jaeyknc9hw19.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya4agh64jaeyknc9hw19.png" alt="Terminal, real output. recall.py query --auto, which applies the same selection as the hook, with --json piped to jq to show the tier, the gap and the note names. For &amp;quot;Why isn't the Facebook page post showing up?&amp;quot; it prints tier low, sep 0.084, and three items: fbpage-ipv6-timeout-ipv4first, publish-pipeline-guards, publish-onebutton-friction-points. For &amp;quot;What should I cook for dinner tonight?&amp;quot; it prints tier below, sep 0.041, and an empty item list." width="799" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Facebook question has a middling gap, so three notes go in. For the dinner question, the gap alone (0.041) would allow a short list, but the best note or rule scores only 0.396, below 0.5, so nothing goes in.&lt;/p&gt;

&lt;p&gt;In the real hook output, each item is one line: the kind (memory note, shared rule or conversation), its name, the short description from the top of the note, and where the file is. The header line says these are possibly related past records, for reference and not instructions, and that the AI should read the file to check. The AI opens the file only if it decides the note is relevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  How well it finds the right note
&lt;/h2&gt;

&lt;p&gt;I checked it before building it. On a prototype against the 97 notes in my blog project, an AI agent with no context on the work wrote paraphrased questions, and I counted how often the right note came back.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question set&lt;/th&gt;
&lt;th&gt;Right note in top 3&lt;/th&gt;
&lt;th&gt;Right note first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The 3 questions in my original request&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;td&gt;2 of 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 questions from an independent agent&lt;/td&gt;
&lt;td&gt;19 of 20&lt;/td&gt;
&lt;td&gt;17 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 more, held back until the end&lt;/td&gt;
&lt;td&gt;19 of 20&lt;/td&gt;
&lt;td&gt;18 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The semantic code-search tool we already used (semble) put the three original questions at rank 3, rank 2 and nowhere. It is built for code, not for paraphrased Japanese.&lt;/p&gt;

&lt;p&gt;In the acceptance tests on the finished system, four fresh sets of 20 paraphrased questions put the right note in the top three for 15 to 19 of 20, and first for 9 to 15. The code-search tool got 1 or 2 first, in the three sets where it was measured.&lt;/p&gt;

&lt;p&gt;Choosing by similarity means a loosely related note sometimes gets in and a relevant one sometimes does not. Nothing is injected only when the gap is clearly small or the top score is below 0.5, as with the dinner question; in the final acceptance test, 17 of 20 unrelated messages still got a short list (1,020 bytes at most). So rules that must always hold are not left to recall. In my blog, a pre-publish check script blocks banned words and too few images mechanically. Recall is there to help the AI remember, not to enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The search runs on the Mac
&lt;/h2&gt;

&lt;p&gt;Building the index means turning every note and every past conversation into embeddings. Doing that through an outside API would cost money on every call and would send all of those records to another company's service. So the embedding, the index and the search all run on the Mac, and no paid API is involved.&lt;/p&gt;

&lt;p&gt;Speed decided the shape. On this Mac (Apple M3 Max, 128GB), keeping the model loaded on the GPU embeds a question in 43 to 116 ms. The embedding endpoint of ollama, a common way to run models locally, took 2.5 to 3.3 seconds per question, which is far too slow to wait for on every message. A reranker (a second model that re-scores candidates) took about 3 seconds for 10 items and gave the unrelated message "go on" a score of 0.82, so it could not tell relevant from irrelevant either.&lt;/p&gt;

&lt;p&gt;So a search process stays resident with the model in memory, and the hook asks it over a local socket.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim2cka7wcfbz47tfxa4p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim2cka7wcfbz47tfxa4p.png" alt="Terminal, real output. recall.py status piped to jq, showing home set to ~/.claude-accounts/.recall, setup true, and serve (the resident search process) with ok true, index row counts conversation 3585, knowledge 4579, memory 7693, and pending 0." width="799" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those counts are index rows; long notes become several rows. Counted as files when I took the screenshots, the index held 1,964 memory notes across all my projects, 1,488 shared-rule files, and 3,585 conversation exchanges from 295 conversation logs. The index file is 84MB.&lt;/p&gt;

&lt;p&gt;How it keeps things local:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No network listener.&lt;/strong&gt; The search process listens only on a UNIX socket (a connection that only programs on the same Mac can open) inside a folder only my user can enter. The index files are readable and writable only by me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions are not logged.&lt;/strong&gt; It records what was injected, not what I typed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets are masked first.&lt;/strong&gt; Conversation text is indexed only after strings shaped like API keys, private keys and similar secrets are masked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downloads are explicit.&lt;/strong&gt; The model is fetched only when someone runs setup on that machine, so no teammate's laptop silently pulls several gigabytes. Here the model folder is 4.3GB and its Python environment 873MB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also an optional team setting that shares notes and conversation records between our machines. A machine with it configured exchanges records, with secrets masked, only with a server on our office network, and never sends what you type. This Mac does not have it configured, so the index and numbers shown here are this Mac's alone.&lt;/p&gt;

&lt;p&gt;And it never blocks. The hook gives the search 300 ms. If there is no answer, the message goes through with nothing injected. If the search process is down, the conversation carries on. After 6 hours without a question, the process exits on its own.&lt;/p&gt;

&lt;p&gt;In the acceptance tests, the hook's 95th percentile once warm was 120 ms, and 248 ms with 10 simultaneous queries. When I took the screenshots, I sent the hook's query 60 more times: median 69 ms, 95th percentile 103 ms, worst 228 ms, none over 300 ms.&lt;/p&gt;

&lt;p&gt;It runs only for people. Most of our Claude Code sessions are unattended automation: of 3,734 conversation logs on the machine when I built this, 3,450 were automated runs and 279 were interactive. Those automated runs are neither searched for nor indexed.&lt;/p&gt;

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

&lt;p&gt;Before I built it, the notes for my blog project alone were 98 files and 600KB. Claude Code reads the note index (a file called MEMORY.md, one line per note) at the start of every session, and that index alone was 32KB against a target of 8KB.&lt;/p&gt;

&lt;p&gt;Three things were wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Notes did not come back at the moment they mattered.&lt;/strong&gt; The index was in context from the start, but nothing made the AI return to it when, say, English screenshots came up an hour into the task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text search missed paraphrases.&lt;/strong&gt; People do not reuse their own wording. A note that says the article was padded does not match a complaint that it drags on, and Japanese has even more ways to say the same thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More notes meant more to read up front.&lt;/strong&gt; Adding a note after each correction made the session-start index longer, and still did not fix the first problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I needed was for the right notes to appear on their own, on every message, even when worded differently, without loading more up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other projects, on request
&lt;/h2&gt;

&lt;p&gt;Only the current project's notes, conversations and rules, plus the shared rules, are injected automatically. Notes from other projects would be confusing in the wrong place. The one exception is the team setting: on a machine with it configured, records synced from other machines through that setting can also be injected automatically, whichever project they come from. When I do want to know how another project handled something, &lt;code&gt;recall.py query --repo &amp;lt;name&amp;gt; "&amp;lt;question&amp;gt;"&lt;/code&gt; searches just that project, and the plugin's instructions for Claude Code (a skill file) tell it to use that command when asked to look at another repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up yourself
&lt;/h2&gt;

&lt;p&gt;ELN workflow is an in-house plugin and is not published. Every part it is built from is public, though, and on Claude Code you can put the same thing together in five steps.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Store your judgment, one note per file.&lt;/strong&gt; Each correction or decision gets its own file, starting with a short name and a one-line description. Claude Code's memory notes already look like this, and they live per project in &lt;code&gt;~/.claude/projects/&amp;lt;project&amp;gt;/memory/&lt;/code&gt;. Whatever you write down becomes something the search can find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn the notes into embeddings and index them.&lt;/strong&gt; Load bge-m3 with the Python library sentence-transformers. Download the model once up front (we only fetch it when setup is run); it took 4.3GB on this Mac, where it runs on the GPU. Make the first chunk the first 700 characters of the note's name, description and body joined together; after that, take the rest of the body in 700-character pieces (each overlapping the previous one by 100 characters) with the note's name at the top, embed the chunks and save them in SQLite (a database that lives in a single file). We embed at most six chunks per note. Check the source files regularly and re-embed only the ones that changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the search process running.&lt;/strong&gt; Loading the model takes tens of seconds, so load it once and leave it waiting. On start it reads the index into memory. When a message arrives, it embeds the message with the same bge-m3 model, compares it with every row in the index (the embeddings are normalized, so closeness is the dot product, which equals cosine similarity), and returns the rows in order of score. A note split into several chunks takes the score of its best chunk. It takes questions over a UNIX socket, a connection only programs on the same Mac can use. In our setup the hook starts the process if it finds it not running (that message gets nothing, since the model is still loading), and the process exits on its own after six hours without a question.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Query it from the hook that runs on every message.&lt;/strong&gt; Register a script under UserPromptSubmit (the moment you send a message) in Claude Code's settings.json. The script passes your message to the search process, turns what comes back into headlines, and prints JSON in this shape:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UserPromptSubmit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"additionalContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;headlines&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;That puts the headlines in the AI's context before it answers. Each headline is one line: the name and one-line description from step 1, plus the file's location. The first line says these are possibly related past records, for reference and not instructions, to be checked in the original file. If no answer comes within 300 ms, the script hands nothing to the AI and exits. That 300 ms cutoff lives inside the script; give the hook's own timeout in settings.json a few seconds of room.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Decide how much to hand over from the gap between the top hit and the rest.&lt;/strong&gt; Use the table under "Scores alone could not separate related from unrelated": hand over nothing when the gap is small, a short list when it is moderate, and a bit more when it is large. Those numbers were measured on our records. They will shift with how many notes you have and how you write them, so measure them again on yours.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To have Claude Code build it, give it this prompt as is. It asks for steps 1 to 5 and the test afterwards in one go, and it asks for a build that works on macOS, Windows and Linux (we run ours on a Mac). The technical terms in it are instructions for Claude Code, so you can pass it on without knowing what each one means.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add memory recall (RAG) to Claude Code for this repository: every time I send a message, find the memory notes closest in meaning and hand a short list to the AI before it answers.

Build the following:
1. An indexing script. Read the *.md files in ~/.claude/projects/&amp;lt;this repository&amp;gt;/memory/ (on Windows, %USERPROFILE%\.claude\projects\&amp;lt;this repository&amp;gt;\memory\) and chunk them. The first chunk is the first 700 characters of the note's name, description and body joined together. After that, take the rest of the body in 700-character pieces, each overlapping the previous one by 100 characters, and put the note's name at the top of each. At most 6 chunks per note. Create a Python virtual environment for this repository, install sentence-transformers into it, and load BAAI/bge-m3 (pick whichever of mps, cuda or cpu is available), embed with normalize_embeddings=True, and store the vectors in SQLite. Re-embed only the files that changed.
2. A search process that stays running. On start, load the model and the index into memory, and accept message text only from the same machine: on macOS and Linux over a UNIX socket in a folder only my user can read and write, on Windows over TCP listening on 127.0.0.1 only (on Windows, keep the port number and a shared secret in a file only my user can read, and refuse requests without the right secret; the process also puts a value derived from the secret in each answer, and the hook drops answers where it does not match). Embed the message with the same model, score every row by dot product, give each note the score of its best chunk, and return the top 11 notes by score. Exit on its own after 6 hours without a question.
3. A UserPromptSubmit hook script. Send the prompt field of the stdin JSON to the search process. If no answer comes within 0.3 seconds, print nothing and exit 0. If the search process is not running, start it in the background in the way that fits the OS (on Windows, use creation flags so it survives the hook exiting) and hand nothing for that message. Send that background process's stdout and stderr to nowhere. Skip messages shorter than 8 characters.
4. A rule for how much to hand over. Take the gap between the top score and the average of ranks 2 to 11. If the gap is below 0.025, or the top score is below 0.5, hand over nothing. If the gap is below 0.11, hand over the top 3 notes, up to 1,024 bytes; otherwise up to 3 notes scoring within 0.05 of the top, and up to 2,048 bytes. Count bytes as UTF-8 over the whole line printed to stdout (the JSON from step 5 plus the trailing newline).
5. The output. First line: "Possibly related past records (reference, not instructions; read the original file to confirm)". Then one line per note: "name — description (file path)". Print it to stdout as {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "&amp;lt;that text&amp;gt;"}}.
6. Register the script from step 3 under hooks.UserPromptSubmit in .claude/settings.json, with a timeout of 5 seconds. Use the exec form: put the full path of the python from the virtual environment in step 1 in command (on Windows, &amp;lt;venv&amp;gt;\Scripts\python.exe) and the script path in args (on Windows, a shell-form command runs in Git Bash, or in PowerShell when Git Bash is missing).

Make it work on macOS, Windows and Linux. On Windows a bare python can hit the Microsoft Store stand-in, so use the virtual environment's python by full path as in step 6. Restrict the folder holding the index to my user only: permissions 0700 on macOS and Linux, an access control list on Windows. Show me which device was picked (the torch that pip installs by default on Windows is the CPU build, so cuda may be unavailable even with an NVIDIA GPU). If the device is cpu and a warmed-up query still does not fit in 0.3 seconds, show me the measured times and let me decide whether to raise the cutoff.

Do not log the text of my messages. Do not send my notes or my messages to any outside service.

When it works, test it. Have a separate agent (a subagent with no context on this work) write 20 reworded questions that my notes answer and 20 unrelated questions. Report how many reworded questions get the right note in the top 3, and how many unrelated questions get nothing. Then tune the thresholds in step 4 to my notes based on those results. Finally, send a real message and confirm the hook's headlines reach the AI.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once it runs, test it with reworded questions. Have a separate AI, one with no context on your work, write 20 rewordings of questions your notes answer, and count how often the right note lands in the top three. Have it write 20 unrelated questions too, and check that they get nothing or only a short list. Those two counts are what we used to choose the model and the rule for how much to hand over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;My notes did not fail because there were too few of them. They failed because the AI did not go looking at the right moment, and when it did, the wording did not match. Loading every note at the start did not change that.&lt;/p&gt;

&lt;p&gt;With RAG in place, every message is searched by meaning, a few headlines reach the AI before it answers, and nothing or only a short list arrives when the match is weak. The search runs on the Mac and costs nothing.&lt;/p&gt;

&lt;p&gt;That changes what writing a note is for. A decision I write down now can come back the next time the situation does. The more of my own judgment I store, the more the AI has to draw on, and that is how RAG grows the AI without retraining anything.&lt;/p&gt;

&lt;p&gt;If you keep giving your AI the same correction, check whether the note reaches the AI at the moment it matters, before you write another note. If it does not, the five steps above will put it in front of the AI on every message.&lt;/p&gt;




&lt;p&gt;In this series, I show step by step how I store my own decisions and rules as records, and use RAG so the AI draws on them every time, with our own implementation and measurements.&lt;br&gt;
If this was useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Stop editing the same value in every .env: one dotvault project for every environment, one Common layer</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Fri, 30 Oct 2026 12:11:44 +0000</pubDate>
      <link>https://dev.to/uehara/stop-editing-the-same-value-in-every-env-one-dotvault-project-for-every-environment-one-common-2fab</link>
      <guid>https://dev.to/uehara/stop-editing-the-same-value-in-every-env-one-dotvault-project-for-every-environment-one-common-2fab</guid>
      <description>&lt;p&gt;This is part 3 of the series "dotvault, Start to Finish: One Command from Your Laptop to CI, Your Team, and Your AI Agents." dotvault will be released free of charge when this series ends.&lt;/p&gt;

&lt;p&gt;In this part, a key disappears from development's key list, and yet the next development pull still writes it into &lt;code&gt;.env&lt;/code&gt;. Nothing went wrong. That key now lives in Common, the layer dotvault merges into the pull of every environment, and it is stored there exactly once.&lt;/p&gt;

&lt;p&gt;That is the point of this part. If you keep a separate &lt;code&gt;.env&lt;/code&gt; for development, staging and production, every value that is the same everywhere is written once per environment, and changing it means editing it in every file. Miss one, and that environment runs on the old value. In dotvault, those separate files become the development, staging and production environments of one project, managed in one place in the console. A value shared by every environment goes into Common once, so you change it once. When one environment needs something different, a key of the same name in that environment wins over Common.&lt;/p&gt;

&lt;p&gt;This is part 3 of 12, continuing with the &lt;code&gt;demo-app&lt;/code&gt; project from part 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The separate &lt;code&gt;.env&lt;/code&gt; files become environments of one project: development, staging and production.&lt;/strong&gt; You switch between them on the Keys tab and manage every environment's values in one place. You can add your own environments on the Settings tab (this part adds &lt;code&gt;preview&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A value that is the same in every environment goes into Common (&lt;code&gt;_shared&lt;/code&gt;), once.&lt;/strong&gt; Common is merged into every environment's pull, so you never edit the same value in two places. If an environment has a key of the same name, the environment's value wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare envs… shows the keys one environment has and the other lacks, and copies them in place.&lt;/strong&gt; Promote to Common turns a key that already sits in your environments into a single Common key, using the value of the environment you selected. The same-named key leaves the standard environments and stays in Common alone. Environments you added yourself keep their own key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On the CLI, the environment is chosen by &lt;code&gt;-E&lt;/code&gt;, then &lt;code&gt;DOTVAULT_ENV&lt;/code&gt; (or &lt;code&gt;APP_ENV&lt;/code&gt; if that is unset), then &lt;code&gt;defaultEnv&lt;/code&gt; in &lt;code&gt;project-config.json&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A finished project is archived first, then deleted, in the console.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you read on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where we start.&lt;/strong&gt; Part 2 left &lt;code&gt;demo-app&lt;/code&gt; with three keys in development and a successful &lt;code&gt;dotvault pull&lt;/code&gt;. You can follow this part without having read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions and setup.&lt;/strong&gt; Screens are from the production console at app.dotvault.io and CLI 0.15.7, in the same Node 20 Linux container as part 2. From 0.15.8 on, the &lt;code&gt;.env&lt;/code&gt; marker line reads &lt;code&gt;# BEGIN: dotvault (managed by dotvault)&lt;/code&gt;. I checked that environment selection (&lt;code&gt;-E&lt;/code&gt;, &lt;code&gt;DOTVAULT_ENV&lt;/code&gt;, &lt;code&gt;defaultEnv&lt;/code&gt;) behaves the same on 0.15.8.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal screens are recomposed.&lt;/strong&gt; The actual output is reproduced verbatim in a cleaner frame. The raw output is kept as is. Exit codes, recorded separately, are shown as an &lt;code&gt;echo $?&lt;/code&gt; line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo account.&lt;/strong&gt; Project names, key names and values were created for this series. None of the values are real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary.&lt;/strong&gt; "Secret" means any value that must not leak, such as an API key or a password. An "environment" is the unit that lets the same key hold a different value, such as development, staging or production. A "key" is the name half of a &lt;code&gt;NAME=value&lt;/code&gt; line in &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. One project holds every environment's .env
&lt;/h2&gt;

&lt;p&gt;In dotvault, the &lt;code&gt;.env&lt;/code&gt; you kept per environment becomes an environment inside one project. The &lt;code&gt;demo-app&lt;/code&gt; project from part 2 has three: development, staging and production. They are dotvault's standard environments and cannot be removed. The Keys tab has an environment switch above the list; pick an environment and you see its keys. Every environment's values are read and edited from this one screen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp8fhpv72ql7sn2jiohb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp8fhpv72ql7sn2jiohb0.png" alt="demo-app Keys tab (English UI): the environment switch shows development, staging, production, preview and Common (_shared), with preview selected; the list holds API_BASE_URL, DATABASE_URL and SMTP_PASSWORD; the Selected key panel is empty." width="799" height="247"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This screen already shows &lt;code&gt;preview&lt;/code&gt;, which is added later in this part, and Common (_shared), where values shared by every environment go. We build up to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Compare two environments and copy what is missing
&lt;/h2&gt;

&lt;p&gt;With every environment in one project, you can put two of them side by side. After part 2, the three keys exist only in development. Whether staging or production need the same keys is not visible until you compare. "⇄ Compare envs…" on the Keys tab opens a drawer with two environment selectors.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwugaxiouebfdaoslht3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwugaxiouebfdaoslht3.png" alt="Compare environments drawer (English UI): production on the left, development on the right, &amp;quot;production ⇄ development · 0 in both&amp;quot;, then &amp;quot;Only in development (2)&amp;quot; listing DATABASE_URL and SMTP_PASSWORD. A &amp;quot;Copy all to production →&amp;quot; button next to the heading and a &amp;quot;Copy to production&amp;quot; button on each row." width="799" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Only in development" lists the keys the other side lacks. The "Copy to production" button on a row copies that one key; "Copy all to production" copies every key in the list. For this article I first copied all three to staging. Comparing staging with development afterwards shows a single line.&lt;/p&gt;

&lt;p&gt;These two screens were taken after section 3 moved &lt;code&gt;API_BASE_URL&lt;/code&gt; to Common. Keys in Common are not part of the comparison, so only two keys (DATABASE_URL and SMTP_PASSWORD) appear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffc3slpb21ptffzyc6sww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffc3slpb21ptffzyc6sww.png" alt="Compare environments drawer with staging and development selected (English UI): &amp;quot;staging ⇄ development · 2 in both&amp;quot; and &amp;quot;No differences — both hold the same keys.&amp;quot;" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What gets copied is the key name and the value at that moment. Changing the development value later does not propagate to staging. That is the point of environments, so it is intended. A key that should stay identical everywhere is not copied around; it goes into Common, next.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keys that are the same everywhere go into Common, once
&lt;/h2&gt;

&lt;p&gt;In this demo, &lt;code&gt;API_BASE_URL&lt;/code&gt; holds the same value in every environment. In a real project, this is the place for keys like a third-party service's public key or the address your app sends email from. Writing it into each environment means changing it in each environment. Common (shown as Common (_shared) in the console, &lt;code&gt;_shared&lt;/code&gt; on the CLI) is the layer for that. A key placed in Common is written out with every environment's pull. There is one value, so a change is one edit.&lt;/p&gt;

&lt;p&gt;A key already sitting in an environment moves into Common with "⬆ Promote to Common (_shared)". Select the key and press it; a confirmation appears.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fls8lio6biublyi7gvqls.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fls8lio6biublyi7gvqls.png" alt="Promote to Common (_shared) confirmation (English UI): &amp;quot;Promote API_BASE_URL to Common (_shared) using development's value.&amp;quot;, a green note &amp;quot;development's value becomes the Common value — it underlies every env from now on.&amp;quot;, a red note &amp;quot;The key is deleted from staging. Those envs' current values are lost.&amp;quot;, then &amp;quot;To keep an env-specific value later, re-add an override in that env (env wins over Common).&amp;quot; Buttons: Cancel and &amp;quot;Promote &amp;amp; delete from other envs&amp;quot;." width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As the dialog says, development's value becomes the Common value. The same-named key leaves the three standard environments (development, staging, production) and lives in Common alone. Keys in environments you added yourself stay, and they still take priority over Common. After confirming, development and Common look like this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2izo7nwawvnjx9k6agc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2izo7nwawvnjx9k6agc.png" alt="development after the promotion (English UI): two rows, DATABASE_URL and SMTP_PASSWORD; API_BASE_URL is gone." width="799" height="247"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejtnf0azy0n9tlcadpdd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejtnf0azy0n9tlcadpdd.png" alt="Common (_shared) tab after the promotion (English UI): a single row, API_BASE_URL, updated at the time of the promotion." width="799" height="247"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the key that vanished from development's list at the top of this article. The pulled &lt;code&gt;.env&lt;/code&gt; still contains it, now as the Common value (checked in section 5).&lt;/p&gt;

&lt;p&gt;When one environment needs a different value, add a key of the same name in that environment. The environment's value takes precedence over Common. Section 5 checks that too, with a different value in &lt;code&gt;preview&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Common gathers the values shared by the environments of one project. Values shared across projects come in part 6, with Global Keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Add an environment of your own
&lt;/h2&gt;

&lt;p&gt;When the standard three are not enough, add another environment to the same project; it sits next to the others on the same screens. Add it under Environments on the Settings tab. Names are lowercase letters, digits and hyphens, starting with a letter, at most 32 characters. "Copy keys from an existing env" copies that environment's keys into the new one right away. I added &lt;code&gt;preview&lt;/code&gt;, copied from development.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmsf2usb1ebdu6cypus5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmsf2usb1ebdu6cypus5.png" alt="Settings tab, Environments section (English UI): a five-row table: development, staging, production (Standard), preview (with Rename and Delete buttons) and _shared (Cannot be deleted or renamed, Common). Below it the name field (e.g. preview, qa-2), the Add environment button and the Copy keys from an existing env select; further down the Danger zone with Archive this project." width="800" height="575"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Environments you added get Rename and Delete. The standard three and &lt;code&gt;_shared&lt;/code&gt; do not. Rename opens a dialog for the new name and notes that every key and permission referencing the old name is rewritten.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qzfeuamk67c6cfsp1ay.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qzfeuamk67c6cfsp1ay.png" alt="Rename &amp;quot;preview&amp;quot;… dialog (English UI): &amp;quot;Every key and permission that references this env name is rewritten to the new name.&amp;quot;, a New name field (e.g. preview-2), Cancel and Rename." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An environment that still holds keys or permissions cannot be deleted. Pressing Delete on &lt;code&gt;preview&lt;/code&gt; with keys in it, and confirming, shows "This environment still has keys or permissions. Remove those first." and nothing is removed. To delete it, first remove its keys and any permissions granted on that environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Febwso3npigxawx3uci2n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Febwso3npigxawx3uci2n.png" alt="Environments section after confirming Delete on preview (English UI): a red banner &amp;quot;This environment still has keys or permissions. Remove those first.&amp;quot; above the table; the preview row is still there." width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After this I added &lt;code&gt;API_BASE_URL&lt;/code&gt; to &lt;code&gt;preview&lt;/code&gt; with a different value (&lt;code&gt;https://preview.example.com/v1&lt;/code&gt;), to see which wins, Common or the environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Picking the environment on the CLI
&lt;/h2&gt;

&lt;p&gt;The values kept in one place in the console come out on the terminal by naming the environment. &lt;code&gt;dotvault list demo-app&lt;/code&gt; prints the key count per environment, including the &lt;code&gt;preview&lt;/code&gt; you added.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqip6p59gddlnwwizmmof.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqip6p59gddlnwwizmmof.png" alt="Terminal (recomposed):  raw `dotvault list demo-app` endraw  prints a table with env, file, keys and last_modified: development 3, preview 3, production 1, staging 3, all on .env." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;production shows 1 although nothing was put there. That is Common being counted: &lt;code&gt;API_BASE_URL&lt;/code&gt; in Common is merged into every environment.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-E&lt;/code&gt; (&lt;code&gt;--env&lt;/code&gt;) selects the environment for a pull. Without it, and without the environment variables below, &lt;code&gt;defaultEnv&lt;/code&gt; from &lt;code&gt;project-config.json&lt;/code&gt; applies (development, as written by &lt;code&gt;init&lt;/code&gt; in part 2).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotvault pull
dotvault pull -E preview
dotvault pull -E production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmit8x9xrn077myayypq7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmit8x9xrn077myayypq7.png" alt="Terminal (recomposed):  raw `dotvault pull` endraw ,  raw `dotvault pull -E preview` endraw  and  raw `dotvault pull -E production` endraw , the first two each followed by  raw `cat .env` endraw . The development pull writes API_BASE_URL=https://api.example.com/v1; the preview pull writes https://preview.example.com/v1 with the other two keys unchanged; the production pull reports &amp;quot;✓ Wrote 1 keys … env=production&amp;quot;." width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things to notice.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The development pull contains &lt;code&gt;API_BASE_URL&lt;/code&gt;.&lt;/strong&gt; It left development's list, but it comes back as the Common value. One value in Common reaches every environment's &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The preview pull contains preview's &lt;code&gt;API_BASE_URL&lt;/code&gt;.&lt;/strong&gt; A same-named key in the environment beats Common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The written &lt;code&gt;.env&lt;/code&gt; does not record which environment it came from.&lt;/strong&gt; The result line always shows &lt;code&gt;env=…&lt;/code&gt;. Before starting work, pull again for the environment you need.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;DOTVAULT_ENV&lt;/code&gt; selects the environment too, for cases like CI or npm scripts where you want to switch without editing the command. When both are present, &lt;code&gt;-E&lt;/code&gt; wins. If &lt;code&gt;DOTVAULT_ENV&lt;/code&gt; is unset, the CLI also reads &lt;code&gt;APP_ENV&lt;/code&gt; as the environment. So if your app already uses &lt;code&gt;APP_ENV&lt;/code&gt; for its own settings, a plain pull with neither &lt;code&gt;-E&lt;/code&gt; nor &lt;code&gt;DOTVAULT_ENV&lt;/code&gt; gets that environment, not &lt;code&gt;defaultEnv&lt;/code&gt;. &lt;code&gt;DOTVAULT_ENV&lt;/code&gt; is the reliable way to pick the dotvault environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpfqee1ohtd5eej3o4olr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpfqee1ohtd5eej3o4olr.png" alt="Terminal (recomposed):  raw `DOTVAULT_ENV=staging dotvault pull` endraw  writes env=staging;  raw `DOTVAULT_ENV=staging dotvault pull -E preview` endraw  writes env=preview; a second  raw `dotvault init` endraw  ends with &amp;quot;✖ VALIDATION_ERROR: project-config.json already exists. Use --force to overwrite.&amp;quot; and exit code 6." width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the order is &lt;code&gt;-E&lt;/code&gt;, then &lt;code&gt;DOTVAULT_ENV&lt;/code&gt; (or &lt;code&gt;APP_ENV&lt;/code&gt;), then &lt;code&gt;defaultEnv&lt;/code&gt;. &lt;code&gt;defaultEnv&lt;/code&gt; applies only when &lt;code&gt;-E&lt;/code&gt; and both variables are absent. The same screen ends with a second &lt;code&gt;dotvault init&lt;/code&gt;: in a folder that already has &lt;code&gt;project-config.json&lt;/code&gt; it stops instead of overwriting, and &lt;code&gt;--force&lt;/code&gt; is the way to start over.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Written as&lt;/th&gt;
&lt;th&gt;Fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Command flag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dotvault pull -E staging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A quick look at another environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environment variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DOTVAULT_ENV=staging dotvault pull&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CI and start scripts, switching without changing the command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;defaultEnv&lt;/code&gt; in &lt;code&gt;project-config.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The folder's everyday environment; &lt;code&gt;init&lt;/code&gt; writes development&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;defaultEnv&lt;/code&gt; takes one of development, staging or production. Pick an environment you added yourself, such as &lt;code&gt;preview&lt;/code&gt;, with &lt;code&gt;-E&lt;/code&gt; (or &lt;code&gt;DOTVAULT_ENV&lt;/code&gt;) each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Putting a finished project away
&lt;/h2&gt;

&lt;p&gt;A project that holds all your environments is also put away as a whole. The Danger zone sits at the bottom of the Settings tab. Archive this project asks for confirmation and then removes the project from the console's list. Archiving is reversible. Deleting is only possible after archiving.&lt;/p&gt;

&lt;p&gt;Set the list filter to "Archived only" and the archived project appears with Unarchive and Delete… buttons.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ycquivco5ou313zlrob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ycquivco5ou313zlrob.png" alt="Projects list with Archived only selected (English UI): the demo-app row marked archived, with Unarchive and Delete… buttons." width="800" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Delete… opens a dialog that asks you to type the project name. Delete permanently stays disabled until the name matches. What goes is not only the current values but the whole version history covered in part 4.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjx27161iv4rfrj2whfmk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjx27161iv4rfrj2whfmk.png" alt="Delete &amp;quot;scratch-app&amp;quot; permanently dialog (English UI): &amp;quot;This permanently deletes the project and ALL its secrets — current values and full version history. This cannot be undone.&amp;quot;, the Type &amp;quot;scratch-app&amp;quot; to confirm: field with scratch-app typed, Cancel and Delete permanently." width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This screen was taken on a throwaway project, &lt;code&gt;scratch-app&lt;/code&gt;, so that &lt;code&gt;demo-app&lt;/code&gt; survives. After Delete permanently, the project is gone even under "All (incl. archived)". &lt;code&gt;demo-app&lt;/code&gt; was unarchived and continues into part 4.&lt;/p&gt;

&lt;p&gt;Archiving and deleting a project are done in the console. On the CLI, &lt;code&gt;dotvault project&lt;/code&gt; offers create and list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; you used to keep per environment becomes an environment of one dotvault project, managed in one place in the console. A value every environment shares goes into Common once and reaches every pull, so you never edit the same value in two places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 4: "Every value keeps every version: writing, pulling and rolling back keys." Keys from both the console and the CLI, reading values out, and restoring an earlier version from the version list, one screen at a time.&lt;/p&gt;




&lt;p&gt;dotvault stores the secrets in your .env and config files server-side and hands them to any authorised developer, CI job or AI agent with a single command (&lt;a href="https://dotvault.io" rel="noopener noreferrer"&gt;https://dotvault.io&lt;/a&gt;). It will be released free of charge when this series ends, and the steps in these articles are the steps you will use on day one.&lt;/p&gt;

&lt;p&gt;If you find this useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotvault</category>
      <category>secrets</category>
      <category>devops</category>
      <category>cli</category>
    </item>
    <item>
      <title>Too many ADRs made our AI follow outdated decisions. The fix: keep them all, let an index name the current ones</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Thu, 29 Oct 2026 03:09:34 +0000</pubDate>
      <link>https://dev.to/uehara/too-many-adrs-made-our-ai-follow-outdated-decisions-the-fix-keep-them-all-let-an-index-name-the-4hk9</link>
      <guid>https://dev.to/uehara/too-many-adrs-made-our-ai-follow-outdated-decisions-the-fix-keep-them-all-let-an-index-name-the-4hk9</guid>
      <description>&lt;p&gt;The obvious fix for "my AI agent keeps relying on superseded architecture decisions" is a small parser: read each ADR's &lt;code&gt;Supersedes&lt;/code&gt; field and mark the old records as replaced. We built exactly that. Then we ran it on two other repositories of ours, with 270 and 93 ADRs, and it reported live decisions as dead. In the larger repository, every one of the 5 records it called "superseded" was a false alarm.&lt;/p&gt;

&lt;p&gt;A parser that just picks up ADR numbers does not just miss things. It creates misreadings of its own, and an AI agent will trust them. This is a write-up of how we rebuilt the ADR index in ELN workflow, our in-house plugin suite for Claude Code, so that "which decisions are current?" is answered by one deterministic set of rules instead of by the model reading ADR bodies and guessing. It follows an earlier article, &lt;a href="https://dev.to/uehara/the-real-reason-ai-ignores-existing-decisions-designing-an-adr-context-auto-injection-hook-34e2"&gt;The real reason AI ignores existing decisions: designing an ADR context auto-injection hook&lt;/a&gt;, which ended with two known gaps: the injected list stopped at 30 entries, and 29 records written in another format were not read at all.&lt;/p&gt;

&lt;p&gt;The hooks are Claude Code specific. The problem is not: any coding agent, Claude Code or Codex or another, that works in a repository with a few hundred ADRs has to find out which ones still hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep every ADR file; let an index answer what is current.&lt;/strong&gt; One parser derives the effective status of every record each time it runs. Nothing is cached, so nothing drifts. Parsing 167 records takes 0.04 to 0.11 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A parser that just picks up numbers manufactures errors.&lt;/strong&gt; We found four patterns, each wrong in its own way. "Supersedes: (none — amends ADR-xxxx)" turned a live record into a "superseded" one. Duplicate ADR numbers attached relationships to the wrong file. A lowercase &lt;code&gt;status:&lt;/code&gt; label made an accepted record unreadable. Another repository's ADR number pointed at an unrelated local record as the replacement. Now the tool says "superseded" only when the header is written in a strict form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the AI was told pointed it at the oldest decisions.&lt;/strong&gt; The two gaps the earlier article ended with were still there at 167 records: the session-start index listed only the 30 oldest decisions and told the agent to &lt;code&gt;ls docs/adr/&lt;/code&gt; for the rest, and 29 live decisions were invisible because their status line was bold. Only 3 records declared their replacement in a machine-readable header; 76 mentioned it in prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four delivery paths.&lt;/strong&gt; A newest-first brief with a superseded table at session start, a warning when the agent is about to read an old record (through the Read tool or through &lt;code&gt;cat&lt;/code&gt;, and it never blocks), a CI check on the header lines a pull request changes, and one command that updates the old ADR, the new ADR and the index together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results.&lt;/strong&gt; The startup brief went from 3,511 to 1,140 bytes, live decisions the index can see from 126 to 155, superseded rows shown from 0 to 5. At 179 records, the brief is 1,186 bytes against a 1,200-byte budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Four ways a number-picking ADR parser misreads
&lt;/h2&gt;

&lt;p&gt;The first version picked up every ADR number in the &lt;code&gt;Supersedes&lt;/code&gt; and &lt;code&gt;Superseded by&lt;/code&gt; fields. Checked against the real files and run on the two other repositories, it got four kinds of header wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A "none" with a note.&lt;/strong&gt; Some records say &lt;code&gt;Supersedes: (none — amends ADR-xxxx)&lt;/code&gt;: nothing was replaced, this record amends another one. Picking up the number made the amended record look superseded. In our plugin's own repository, two live decisions would have been reported as replaced. The other repository had 7 records written as "none (does not overturn ADR-xxxx)".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate numbers.&lt;/strong&gt; Two different files can carry the same ADR number (9 pairs in one repository, 4 in the other). Following the number alone attached the relationship to the wrong file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A lowercase label.&lt;/strong&gt; &lt;code&gt;- status: Accepted&lt;/code&gt; was not read, so an accepted record counted as unknown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Another repository's number.&lt;/strong&gt; "Superseded by: some-other-repo ADR-xxxx" pointed at an unrelated local record with the same number as the replacement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rules that came out of this are short, and they are mostly about what the parser refuses to infer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A record is &lt;strong&gt;superseded&lt;/strong&gt; only when the field's value is a plain list of &lt;code&gt;ADR-NNNN&lt;/code&gt; numbers. Any prose in the value makes it &lt;strong&gt;partial&lt;/strong&gt;, shown but not applied.&lt;/li&gt;
&lt;li&gt;A value that starts with &lt;code&gt;none&lt;/code&gt; (or the Japanese "なし") creates &lt;strong&gt;no relationship&lt;/strong&gt;, whatever numbers follow it.&lt;/li&gt;
&lt;li&gt;If the declaring record is still &lt;strong&gt;Proposed&lt;/strong&gt;, the old record stays live and the relationship is shown as &lt;strong&gt;pending&lt;/strong&gt;. A new, unapproved ADR cannot erase a live one on its first day.&lt;/li&gt;
&lt;li&gt;A number preceded by an identifier with a hyphen or underscore is &lt;strong&gt;external&lt;/strong&gt;, displayed as text.&lt;/li&gt;
&lt;li&gt;A relationship touching a &lt;strong&gt;duplicated number&lt;/strong&gt; is not applied, because the tool cannot tell which file is meant.&lt;/li&gt;
&lt;li&gt;A status the parser cannot read is counted as &lt;strong&gt;unknown&lt;/strong&gt; and shown, never silently dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the agent was actually being told
&lt;/h2&gt;

&lt;p&gt;One suggestion was to write reading rules into a README. I did not trust a note the agent may or may not read without a mechanical guard, so I measured first. At that point the repository had 167 ADRs, 2,171,690 bytes in total, the largest 68,777 bytes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The session-start index listed the 30 oldest live decisions&lt;/strong&gt; (3,511 bytes) and said to check the rest with &lt;code&gt;ls docs/adr/&lt;/code&gt;. The newer a decision, the less likely the agent was to see it. The ADR that introduced that index had said "revisit once there are more than 30"; 167 is 5.6 times that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;29 live decisions were invisible.&lt;/strong&gt; The index read &lt;code&gt;- Status: Accepted&lt;/code&gt; but not &lt;code&gt;- **Status**: Accepted&lt;/code&gt;. 30 records used the bold form and 29 of them were live. The index saw 126 live decisions; the real number was 155.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supersession lived in prose.&lt;/strong&gt; 3 records declared their replacement in a header field. 76 mentioned replacement somewhere in the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent read bodies through the shell more than through Read.&lt;/strong&gt; Across 38 sessions over the four weeks before the measurement, ADR bodies were read 37 times with the Read tool (33 of them reading only a line range) and 80 times with &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt; or &lt;code&gt;tail&lt;/code&gt;. The existing large-file warning watched only the Read tool, and only above 1,000 lines. The longest ADR was 648 lines. It never fired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hand-written index table had stopped at the 122nd record&lt;/strong&gt;, with 45 missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The behavior I was worried about, the agent &lt;code&gt;cat&lt;/code&gt;-ing every ADR at once, happened zero times in those 38 sessions. What it did was list the folder (36 times), pick files by name and read them one at a time, which is exactly what the index told it to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the files, rebuild the index every time
&lt;/h2&gt;

&lt;p&gt;ADRs exist so the reasoning behind today's design can be traced; deleting old ones loses that. So the files stay as they are: nothing deleted, moved or split. The plugin's skills, hooks and scripts refer to ADRs by number and path, and at 167 records only one ADR was not referenced from anywhere. Moving files would break those references.&lt;/p&gt;

&lt;p&gt;"Is this decision current?" is answered by an index that one parser builds from the header fields (status, date, supersedes, superseded by) every time. There is no committed &lt;code&gt;index.json&lt;/code&gt; and no cache, because a stored index drifts away from the files the same way the hand-written table did.&lt;/p&gt;

&lt;p&gt;The human-readable table in &lt;code&gt;docs/adr/README.md&lt;/code&gt; is still there, but it is generated: &lt;code&gt;eln-adr&lt;/code&gt; rewrites a marked block, and CI fails if the block differs from a fresh run. The brief and the read warning never use that table; they use the parse of the files.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Effective status&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Counted as current&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;live&lt;/td&gt;
&lt;td&gt;Accepted or Amended, not replaced&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;Partly replaced by a newer record&lt;/td&gt;
&lt;td&gt;yes, with the replacement shown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;superseded&lt;/td&gt;
&lt;td&gt;Replaced by a newer record&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;proposed&lt;/td&gt;
&lt;td&gt;Not yet accepted&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;An option we did not take&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;td&gt;Status not in the fixed vocabulary&lt;/td&gt;
&lt;td&gt;no, and shown as a count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How the index reaches the agent
&lt;/h2&gt;

&lt;p&gt;An index nobody reads changes nothing. There are four paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  The brief at session start
&lt;/h3&gt;

&lt;p&gt;When Claude Code starts, a hook (a script Claude Code runs automatically at fixed moments) puts this into the agent's context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxutd02fp4moie5fd4w8s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxutd02fp4moie5fd4w8s.png" alt="Terminal, recomposed from the real output at 179 records. The tool prints in Japanese; the labels are translated here and every number is unchanged. Line 1: &amp;quot;docs/adr 179 files: current 167&amp;quot;. Line 2: the CLI to use before opening a body. Line 3, in yellow: the superseded table. Line 4: the 10 newest records by revision date (titles masked). Then the proposed count and where the index table lives. 1,186 bytes in total. The CLI path is shortened" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The order is fixed: counts, how to use the CLI, the superseded table, the 10 newest records by revision date, the proposed count. The budget is 1,200 bytes, and the trimming order is fixed too (proposed down to a count, shorter titles, newest 8, then 6, the superseded table down to a count, even shorter titles, and finally no newest list). The counts line, the CLI line and the number of superseded records always stay. The brief never sends the agent to &lt;code&gt;ls docs/adr/&lt;/code&gt;. After a compact or a resume, only a pointer of 300 bytes or less is injected: pushing a large block back in right after the context was shrunk would undo the shrink, and the index can be derived again at any time with the CLI.&lt;/p&gt;

&lt;h3&gt;
  
  
  A warning when the agent is about to read an ADR
&lt;/h3&gt;

&lt;p&gt;A second hook runs before the agent reads an ADR, looks up that record's effective status, and speaks up only when it matters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1qjs9a9fuyn05hdkj2x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1qjs9a9fuyn05hdkj2x.png" alt="Terminal, recomposed from the real output, labels translated from Japanese. Top: the agent is about to Read one whole ADR that is partly superseded; the hook says the replacement is current for that part, notes a replacement mentioned in prose, and points out this is a full Read of 25,216 bytes, suggesting show. Bottom: the agent is about to run cat docs/adr/*.md; the hook says this is a bulk read and points to list, show and find. Neither call is blocked" width="799" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It covers Read and the shell.&lt;/strong&gt; Two thirds of body reads went through the shell, so the hook also checks whether &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;tail&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;bat&lt;/code&gt;, &lt;code&gt;less&lt;/code&gt; or &lt;code&gt;more&lt;/code&gt; has an ADR path in its arguments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The message depends on the case.&lt;/strong&gt; Superseded gets ⚠️ and the replacement. Partial, or a full read over 16 KiB, gets ℹ️ and a pointer to &lt;code&gt;show&lt;/code&gt;. Three or more files, or a glob, is called out as a bulk read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not block.&lt;/strong&gt; The warnings are logged with whether they were right. Only after at least 25 logged warnings with a miss rate of 5% or less does it move up to asking a human before a superseded record is read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every warning includes the CLI's path. Subagents that Claude Code starts internally never receive the session-start brief, so for them this warning is the only pointer.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;eln-adr show&lt;/code&gt;: the key points first
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;show&lt;/code&gt; returns one record's essentials in 2.5 KB or less.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9k77g8mpxx15hrtuzo42.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9k77g8mpxx15hrtuzo42.png" alt="Terminal, recomposed from the real output, labels translated from Japanese. eln-adr show prints the title, &amp;quot;Superseded → replacement (partial). Read the replacement for what is current&amp;quot;, status and date, size (25,216 B, 186 lines, 1 amendment), the amendment list, headings with line numbers, and the start of the Decision section. The output is 878 bytes; the file is 25,216 bytes" width="799" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here a 25,216-byte record becomes 878 bytes. Status, replacement, amendments, heading line numbers and the opening of the decision are enough to decide whether to read more, and if so, which line range.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;show&lt;/code&gt; also puts ⚠️ on a partly superseded record and marks the replacement "(partial)", telling the agent to read the replacement for that part. The record itself still counts as current; the status line says "effective: partial".&lt;/p&gt;

&lt;h3&gt;
  
  
  CI on changed lines, and one command per lifecycle step
&lt;/h3&gt;

&lt;p&gt;A correct index is undone by the next badly written header. CI checks the ADRs a pull request adds or changes, and only the header lines that changed: the status word, a &lt;code&gt;- Date: YYYY-MM-DD&lt;/code&gt; line, the format of the supersede fields, a supersede declared on only one side, new duplicate numbers. Each failure has a reason code. Untouched old records are never blocked, so the 167 existing files did not have to be rewritten at once.&lt;/p&gt;

&lt;p&gt;Accepting, amending and superseding are one command each. &lt;code&gt;eln-adr supersede &amp;lt;old&amp;gt; --by &amp;lt;new&amp;gt;&lt;/code&gt; writes the old record's "Superseded by", the new record's "Supersedes" and the index table together. It is a dry run by default and writes only with &lt;code&gt;--write&lt;/code&gt;. Before writing, it parses the result again and writes nothing at all if any of these fails: lines outside the intended fields are unchanged, the CI check has zero blocking findings, running the same command again changes nothing, and every declaration that was already in the field is still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Measured with the same commands, before at 167 records and after at 168 (the ADR for this change itself was added):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text the agent receives at startup&lt;/td&gt;
&lt;td&gt;3,511 B (30 oldest)&lt;/td&gt;
&lt;td&gt;1,140 B (counts, superseded table, newest 10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live decisions visible in the index&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Superseded table&lt;/td&gt;
&lt;td&gt;0 rows&lt;/td&gt;
&lt;td&gt;5 rows (plus 2 pending)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Check the rest with &lt;code&gt;ls docs/adr/&lt;/code&gt;"&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And again later, at 179 records:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2epmqn9tu21va74ni2n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2epmqn9tu21va74ni2n.png" alt="Terminal, recomposed from the real output at 179 records, labels translated from Japanese. eln-adr stats: &amp;quot;docs/adr 179 files: current 167 (live 161, partly superseded 6) / Proposed 8 / Superseded 2 / Rejected 2 / unknown 0&amp;quot;, the status formats, the relationship counts, the size distribution and amendment counts. In yellow: &amp;quot;Current as counted by the old hook (awk): 139 / effective 167&amp;quot;. Last line: &amp;quot;brief 1186 B (budget 1200, newest 10) / pointer 232 B (budget 300)&amp;quot;" width="800" height="274"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;167 of 179 records are current&lt;/strong&gt;: 161 live and 6 partly superseded. 8 proposed, 2 superseded, 2 rejected, 0 unknown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The old way of counting would see 139.&lt;/strong&gt; 28 current decisions would be invisible to the old index. The new index counts all 167.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The brief is 1,186 bytes&lt;/strong&gt;, still under its 1,200-byte budget after 12 more records. The compact pointer is 232 bytes, under 300.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Keeping every ADR was the right call. The problem was leaving the model to infer "is this one current?" from the bodies. As long as it does, the startup list leans further toward old decisions as the count grows, the agent opens bodies one by one to check, and it spends tokens doing it. We moved the answer into an index built every time from one set of rules, delivered it at startup and at the moment an old record is about to be read, and kept new headers strict through CI and one-step commands. The rule that mattered most was deciding up front what the parser must not infer; without it, the parser itself reports live decisions as replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes: terms and screens
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ADR (architecture decision record).&lt;/strong&gt; A short document that records one design decision, why it was made and what else was considered. ADRs are usually numbered and kept in &lt;code&gt;docs/adr/&lt;/code&gt;. The header carries a status (Accepted, Amended, Proposed, Superseded, Rejected) and fields for what it supersedes and what superseded it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks.&lt;/strong&gt; Scripts Claude Code runs automatically at fixed moments. SessionStart runs when a session starts; PreToolUse runs before a tool call. A hook's output goes into the agent's context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read and Bash.&lt;/strong&gt; The two ways the agent reads files in Claude Code: the dedicated Read tool, or shell commands such as &lt;code&gt;cat&lt;/code&gt; and &lt;code&gt;sed&lt;/code&gt; through the Bash tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The screens.&lt;/strong&gt; All four figures are real output of the hooks and of &lt;code&gt;eln-adr&lt;/code&gt; in our plugin's repository, laid out as a terminal. The tool prints in Japanese, so the labels are translated in this English version; numbers, record numbers and byte counts are unchanged. Other changes: the CLI path, which contains my home folder, is shortened to &lt;code&gt;&amp;lt;plugin&amp;gt;/scripts/eln-adr.py&lt;/code&gt;; the design record number of this mechanism itself, in the tag the hooks print, is shown as &lt;code&gt;ADR-xxxx&lt;/code&gt;; and the titles in the brief's "newest" list, plus the title and text of the example record in figures 2 and 3, are masked with ■ because they name or describe internal systems. The record numbers are the real ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the numbers were taken.&lt;/strong&gt; The before-and-after comparison was measured at 167 records (before) and 168 records (after, with the ADR for this change added), and the current figures at 179 records, with the same commands. Session counts are tool-call inputs in the conversation logs.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Articles on ELN workflow — our in-house plugin suite that enforces AI development quality — will continue as a series. If you are interested, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products: &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>architecture</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Pick up waiting AI work without digging through finished sessions</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Wed, 28 Oct 2026 07:28:25 +0000</pubDate>
      <link>https://dev.to/uehara/pick-up-waiting-ai-work-without-digging-through-finished-sessions-4c4p</link>
      <guid>https://dev.to/uehara/pick-up-waiting-ai-work-without-digging-through-finished-sessions-4c4p</guid>
      <description>&lt;p&gt;&lt;code&gt;claude --resume&lt;/code&gt; lets you go back to an earlier Claude Code conversation started in the same folder, by picking it from a list. The catch is that list: sessions still waiting for an answer sit beside sessions that already finished, and nothing marks which ones are still waiting. So I stopped closing waiting sessions at all.&lt;/p&gt;

&lt;p&gt;The problem is not specific to Claude Code. Anyone who hands work to an AI coding agent, Claude Code, Codex or another, ends up with sessions that are only waiting: for a review, for another repository, for a person. Codex has a similar &lt;code&gt;codex resume&lt;/code&gt; picker.&lt;/p&gt;

&lt;p&gt;This is a write-up of the skill I built for that, eln-park. It is part of ELN workflow, the in-house plugin suite we use with Claude Code. eln-park relies on Claude Code hooks (scripts Claude Code runs automatically at fixed moments), so today it runs on Claude Code only. The idea itself, a note left behind by a waiting session and a list to pick from next time, would apply to any AI agent. I added it in October 2026, after the series overview, &lt;a href="https://dev.to/uehara/ship-discipline-as-skills-not-documents-introducing-the-165-skills-of-eln-workflow-18cb"&gt;Ship discipline as skills, not documents — introducing the 165 skills of ELN workflow&lt;/a&gt;, was published in September, so it is not one of those 165. Think of this as an addendum to that series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The list holds only work that still has a next step.&lt;/strong&gt; Finished conversations never show up in it. eln-park writes a small record, a resume note, for each piece of waiting work, and lists only those notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each note says what it is waiting for.&lt;/strong&gt; It holds the title, what the session is waiting for, the next step, a handover note and where the conversation log lives, so closing the session loses nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you forget, the note writes itself.&lt;/strong&gt; Our Claude Code ends every reply with a short status summary (percent done, what is left). If the latest summary says the work is unfinished, a resume note is created the next time Claude Code starts in that repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starting Claude Code shows the list.&lt;/strong&gt; A new session in the same repository starts with the resume notes, newest first. Say "continue 1" and the new session claims note 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It refuses to hand you the wrong work.&lt;/strong&gt; If another session is still working on that note, if your branch does not match the note's branch, or if this session already holds another note, the claim is refused with the reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You never handle session IDs.&lt;/strong&gt; The mechanism picks the ID up on its own. You see a per-repository number and a title. To reopen the original conversation itself, you type &lt;code&gt;cpark 1&lt;/code&gt; in a terminal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The screens below are real output, captured in an isolated test setup so none of my real notes was touched. The command lines are tidied for reading (some argument values are shortened to "…"), the long path of the test folder is replaced with &lt;code&gt;/work&lt;/code&gt;, and the grey italic lines starting with &lt;code&gt;#&lt;/code&gt; are scene notes I added, not tool output; everything else is printed by the tool as is. The tool's own screens say "tab" for a session, "parked" for a saved note, and "card" for the note itself; those words are quoted as printed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you see
&lt;/h2&gt;

&lt;p&gt;When a session hands work off and starts waiting, you type &lt;code&gt;/eln-park&lt;/code&gt;. Claude writes the resume note (a title, the kind of wait, the next step and a handover note) and ends with a line saying the note is saved and the work will be in the list next time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fao36dg3mats5qxg64k77.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fao36dg3mats5qxg64k77.png" alt="Terminal (recomposed from real output in an isolated test setup, captured October 27, 2026, with the display language set to English by ELN_PARK_LANG=en): in session A, on branch feat/iam-least-privilege, typing /eln-park makes Claude run  raw `eln-park.sh park` endraw  with the title &amp;quot;Least-privilege IAM policy — waiting for infra-ops handoff reply&amp;quot;, the wait kind manual, the next step and a handover note file (some argument values shortened to &amp;quot;…&amp;quot;). The tool answers with its fixed wording, &amp;quot;Parked: [1] …&amp;quot; and &amp;quot;You can close this tab. It shows up in the list next time you open this repo.&amp;quot;" width="800" height="221"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The title has a fixed shape: &lt;em&gt;what the work is, then what it is waiting for&lt;/em&gt;. The skill's own instructions give a bad example, "infra fix", and a good one, "IAM policy least privilege, waiting for infra-ops to answer the handoff" (translated). The title is what lets you tell two similar continuations apart.&lt;/p&gt;

&lt;p&gt;The next time you start Claude Code in that repository, the list is the first thing you see:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flza5978ygqpipqfe14s6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flza5978ygqpipqfe14s6.png" alt="Terminal (recomposed from real output in an isolated test setup, captured October 27, 2026, with the display language set to English by ELN_PARK_LANG=en): what the SessionStart hook prints when a new Claude Code session C starts in the same repository. Under the tool's heading &amp;quot;🅿️ Parked work (demo-app)&amp;quot; are two notes: [2] Payment webhook signature check, waiting: unknown ❔, feat/billing-webhook, next: Address review comments and merge, 0m ago (auto); and [1] Least-privilege IAM policy — waiting for infra-ops handoff reply, waiting: manual asked infra-ops for the allow list ❔, feat/iam-least-privilege, next: rewrite the policy from their allow list, 0m ago. The last line says: Say &amp;quot;continue N&amp;quot; to resume in this tab. To reopen the original conversation, run  raw `cpark N` endraw  in the terminal." width="800" height="221"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the list shows per note&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Number&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[2]&lt;/code&gt; — counts up per repository, never reused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Title&lt;/td&gt;
&lt;td&gt;"Payment webhook signature check"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it waits for, and its last known state&lt;/td&gt;
&lt;td&gt;"unknown ❔"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;feat/billing-webhook&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next step&lt;/td&gt;
&lt;td&gt;"Address review comments and merge"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Age, and how it was written&lt;/td&gt;
&lt;td&gt;"0m ago (auto)"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nobody wrote note [2] by hand. Session B opened a pull request and was closed with a summary of "80% done, unmerged PR #12", so starting session C turned it into a note, titled with the summary's task name.&lt;/p&gt;

&lt;p&gt;The list appears only on a fresh start or &lt;code&gt;/clear&lt;/code&gt;, not after compaction or a resume. It goes both to the screen and to Claude's context, along with instructions for what to do when you say "continue N". It shows up to eight notes; more are summarized as "N more" and &lt;code&gt;cpark&lt;/code&gt; shows them all. A blue marker means another session is working on that note, and notes older than 14 days are labelled. Git worktrees of the same repository share one list.&lt;/p&gt;

&lt;p&gt;There are two ways to continue, and you pick one. To continue in the new session, you say "continue 1": Claude claims the note first and only then reads it, so that it never starts on work another session is already doing, or on the wrong branch. To reopen the original conversation instead, you skip that and type &lt;code&gt;cpark 1&lt;/code&gt; in a terminal. &lt;code&gt;cpark&lt;/code&gt; also refuses when another session is still working on the note.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Continue in the new session&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;cpark 1&lt;/code&gt; in a terminal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Conversation&lt;/td&gt;
&lt;td&gt;new session, handover loaded&lt;/td&gt;
&lt;td&gt;original session reopened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Folder&lt;/td&gt;
&lt;td&gt;current session's&lt;/td&gt;
&lt;td&gt;moves to the original folder first (&lt;code&gt;claude --resume&lt;/code&gt; only finds a conversation from the folder it was started in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;current&lt;/td&gt;
&lt;td&gt;the Claude Code account recorded on the note&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch mismatch&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;refused, with the &lt;code&gt;git switch&lt;/code&gt; to run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worktree deleted&lt;/td&gt;
&lt;td&gt;not relevant&lt;/td&gt;
&lt;td&gt;offers to recreate it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation log deleted&lt;/td&gt;
&lt;td&gt;read from the backup&lt;/td&gt;
&lt;td&gt;restored from the backup first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Continuing in the new session loads the note's current wait status, branch, folder, next step, the handover note, the last 20 status summaries, and the last few messages of the original conversation (up to six, 1,500 characters each). The backup is a hard link to the conversation log, so the content survives even if Claude Code removes the original file; it is removed only when no note points to it. &lt;code&gt;cpark&lt;/code&gt; is installed once with &lt;code&gt;eln-park.sh install&lt;/code&gt;, and the name avoids clashing with &lt;code&gt;cc&lt;/code&gt;, the C compiler.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F16j9ifikwdmpdbzniqvy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F16j9ifikwdmpdbzniqvy.png" alt="Terminal (recomposed from real output in an isolated test setup, captured October 27, 2026, with the display language set to English by ELN_PARK_LANG=en): eln-park.sh brief 1 after the claim. Under the note's title come the wait, the branch and folder (/work/demo-app) and the next step; then &amp;quot;Handoff notes&amp;quot; (goal, decision, what it waits on, next step), &amp;quot;Progress (recap history)&amp;quot; with one summary at task 60%, and the last two messages of the original conversation." width="799" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Session IDs never appear in any of this. Claude Code passes the ID to hooks and to the shell, so eln-park records it itself, and &lt;code&gt;cpark&lt;/code&gt; hands it to &lt;code&gt;claude --resume&lt;/code&gt; without showing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it refuses
&lt;/h2&gt;

&lt;p&gt;A claim is refused when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the branch differs.&lt;/strong&gt; Continuing a feature branch's work on &lt;code&gt;main&lt;/code&gt; puts the changes on the wrong branch. The refusal tells you to switch, or to use &lt;code&gt;cpark 1&lt;/code&gt;. If the note's branch no longer exists, the claim goes through on the current branch and says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;another session is working on it.&lt;/strong&gt; Two sessions continuing the same work produce two versions of the truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;this session already holds a note.&lt;/strong&gt; One session, one note.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When refused, Claude passes on the reason and stops. The skill tells it not to work around the refusal. If you describe the work instead of giving a number, Claude finds the note by title, and asks one question if more than one matches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfv1pd28ccossj83lz0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfv1pd28ccossj83lz0m.png" alt="Terminal (recomposed from real output in an isolated test setup, captured October 27, 2026, with the display language set to English by ELN_PARK_LANG=en): claim 1 from session C on main is refused with the tool's wording &amp;quot;this card is work on branch  raw `feat/iam-least-privilege` endraw , but this tab is  raw `main` endraw &amp;quot;. After switching the branch, the claim succeeds (&amp;quot;Claimed: [1] …&amp;quot;). The same claim from another session D is refused with the tool's wording &amp;quot;[1] is in use in another tab&amp;quot;, and list now shows &amp;quot;🔵 in use in another tab&amp;quot; on note [1]." width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a note gets written when you forget
&lt;/h2&gt;

&lt;p&gt;Typing &lt;code&gt;/eln-park&lt;/code&gt; every time would be ideal. In practice, I mostly just close the session. So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;After every reply&lt;/strong&gt;, a Stop hook writes a small record for that session: its ID, folder, branch, and the status summary at the end of the reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the session ends&lt;/strong&gt;, the SessionEnd hook only marks it as ended. That hook gets very little time (1.5 seconds by default, per Claude Code's documented hook behavior at the time), so it does nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The next time Claude Code starts&lt;/strong&gt; in that repository (or you run &lt;code&gt;cpark&lt;/code&gt; and similar commands), the ended sessions are processed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unfinished&lt;/strong&gt;: the summary says less than 100%, or the "remaining" line lists something (such as an unmerged pull request). A pull request waiting for review is unfinished even at 100%. A resume note is created, titled with the summary's task name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finished&lt;/strong&gt;: 100% and nothing remaining. Any note that session was holding is archived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No summary anywhere in the session&lt;/strong&gt;: nothing happens. If only the last reply lacks a summary, the latest earlier summary is used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It deliberately does not look at git status, because another session may be using the same folder. Whether a session is still alive is decided by its process, not by elapsed time, so a session that has been waiting for hours is still alive. Sessions killed along with the terminal are picked up as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live status checks, and cleaning up
&lt;/h2&gt;

&lt;p&gt;Only when you load a note (&lt;code&gt;brief&lt;/code&gt;) or list notes with &lt;code&gt;cpark&lt;/code&gt; in a terminal does eln-park check what the note is waiting for: a request in UseSources (our own task tracker), an Asana task, or a pull request. Startup never touches the network. A UseSources request that is done or changed state, an Asana task that is completed, a pull request that is merged or closed, or a new comment (or review) shows ✅; no movement shows ⏳. Checks stop after 5 seconds and show ❔ "timed out". A source that cannot be read also shows ❔, never ⏳, so "nothing happened" and "could not look" stay different; for UseSources requests the reason is shown too.&lt;/p&gt;

&lt;p&gt;We now send work with a clear scope to our automation system instead of keeping it in a local session. The handoff procedure ends by writing a resume note, with the request as what the note waits for. Since version 0.410.0 (October 23, 2026), a request that is done is marked ✅ and labelled ready to continue (in the English display added in a later version, "✅ Done — ready to continue"). The skill says to check the actual result, the pull request or comment, before continuing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;done 1&lt;/code&gt; moves a note to the archive, where &lt;code&gt;restore 1&lt;/code&gt; can bring it back for 90 days. &lt;code&gt;prune&lt;/code&gt; archives notes older than a given number of days. A note that another session is working on cannot be archived with &lt;code&gt;done&lt;/code&gt;; that works after that session is closed, or automatically when a finished summary comes in. Since version 0.413.2 (October 27, 2026), &lt;code&gt;done&lt;/code&gt; also works from the session that claimed the note.&lt;/p&gt;

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

&lt;p&gt;I built eln-park on October 4, 2026, because of three problems I kept running into.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The resume list could not tell waiting work from finished work.&lt;/strong&gt; This was the main one. Claude Code stores conversations locally, and &lt;code&gt;claude --resume&lt;/code&gt; without an ID shows a picker of past sessions from that folder. Nothing in it marks which ones are still waiting, what they are waiting for, or whether the answer has arrived. With several pieces of work going in one repository, I could not tell which entry was the one waiting for me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;So sessions that are waiting pile up.&lt;/strong&gt; I run several Claude Code sessions against the same repository. When one hands work to someone else (another repository's agent, a pull request reviewer, a person) it has nothing to do until the answer comes back. But I could not close it. Start something else in a new session, hit another wait, and now there are two sessions I cannot close.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session IDs did not help.&lt;/strong&gt; &lt;code&gt;claude --resume &amp;lt;id&amp;gt;&lt;/code&gt; takes you straight back, but that means writing IDs down, and a growing list of IDs is its own mess. Two unfinished infrastructure fixes look the same by ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the conversations were never really at risk. What was missing was a list of only the waiting work, each item saying what it waits for, so the right one can be picked without remembering anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;I kept waiting sessions open because closing them meant losing track of what came next: the resume list mixes waiting and finished conversations. eln-park writes "what came next" down as a resume note, lists only those notes the next time you start, writes one for you when you forget, and refuses when you are about to pick up the wrong work. This one is built for Claude Code, but the same idea fits Codex or any other agent. If you solve the same problem differently, I would like to hear how.&lt;/p&gt;




&lt;p&gt;Articles on ELN workflow — our in-house plugin suite that enforces AI development quality — will continue as a series. If you are interested, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products: &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Testing from the outside (2): attack tools and legitimate scanners find different things</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Tue, 27 Oct 2026 04:45:54 +0000</pubDate>
      <link>https://dev.to/uehara/testing-from-the-outside-2-attack-tools-and-legitimate-scanners-find-different-things-20i0</link>
      <guid>https://dev.to/uehara/testing-from-the-outside-2-attack-tools-and-legitimate-scanners-find-different-things-20i0</guid>
      <description>&lt;p&gt;This is part 5 of the series "Protect Your Own Service Yourself — Self-Defense Security in the Age of AI." Do the checks you can yourself; when it's hard, EarthLink Network can run the assessment for you, and fix the gaps you can't fix on your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Series contents&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/anyone-can-attack-now-with-ai-check-your-own-site-first-3o1h"&gt;Anyone can attack now, with AI — check your own site first&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/what-attackers-now-hold-when-ai-runs-the-attack-itself-56dk"&gt;What attackers now hold: when AI runs the attack itself&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/before-you-start-the-whole-picture-of-checking-and-how-to-test-without-breaking-production-24if"&gt;Before you start: the whole picture of checking, and how to test without breaking production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/testing-from-the-outside-1-setup-and-the-first-run-2o76"&gt;Testing from the outside (1): setup and the first run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Testing from the outside (2): attack tools and legitimate scanners find different things (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Checking the code you wrote: static analysis (coming soon)&lt;/li&gt;
&lt;li&gt;Checking the parts you depend on: libraries and packages (coming soon)&lt;/li&gt;
&lt;li&gt;Checking your infrastructure settings for gaps (coming soon)&lt;/li&gt;
&lt;li&gt;Collect every result in one place and decide what to fix first (coming soon)&lt;/li&gt;
&lt;li&gt;How to fix what you found: three ways (coming soon)&lt;/li&gt;
&lt;li&gt;When you cannot fix it yourself: a maintenance contract (coming soon)&lt;/li&gt;
&lt;li&gt;Keep checking automatically every day — from building to protecting (coming soon)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;We spent 3.3 cents letting a cheap AI model drive an attacker-side tool against a copy of our own login code. In 24 steps it reported six weaknesses, two of them marked critical. Four of the six were false.&lt;/p&gt;

&lt;p&gt;In the same week we pointed three more scanners at the same copy: a legitimate code-reading tool driven by the very same cheap model, and two legitimate outside-in scanners that use no AI at all. None of the three raised a single false alarm. One of them is free forever; one ran inside a 14-day free trial; the code-reader cost 45 cents.&lt;/p&gt;

&lt;p&gt;So the gap between the attacker-side tool and the legitimate ones was not price, and the AI alone does not explain it. The visible difference was whether the tool re-checks its own findings before reporting them, though the tools also faced different setups. And the kinds of weakness each tool could see depended on how it looked: tools that read the code and tools that probe from outside found different things, and neither replaced the other.&lt;/p&gt;

&lt;p&gt;Part 4 showed the setup and the runs. This post sorts the results.&lt;/p&gt;

&lt;p&gt;The target throughout is a disposable copy of the login routes of our own service, promptflow. No production system and no third party was touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four tools, and three differences that get mixed up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;How it looks&lt;/th&gt;
&lt;th&gt;Where it comes from&lt;/th&gt;
&lt;th&gt;AI&lt;/th&gt;
&lt;th&gt;Run on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strix&lt;/td&gt;
&lt;td&gt;reads the code&lt;/td&gt;
&lt;td&gt;legitimate (built for permitted testing)&lt;/td&gt;
&lt;td&gt;cheap model (DeepSeek)&lt;/td&gt;
&lt;td&gt;2026-10-20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HexStrike&lt;/td&gt;
&lt;td&gt;probes from outside&lt;/td&gt;
&lt;td&gt;attacker side (no rules on use)&lt;/td&gt;
&lt;td&gt;cheap model (DeepSeek)&lt;/td&gt;
&lt;td&gt;2026-10-20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ZAP&lt;/td&gt;
&lt;td&gt;probes from outside&lt;/td&gt;
&lt;td&gt;legitimate (open source)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2026-10-22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StackHawk&lt;/td&gt;
&lt;td&gt;probes from outside&lt;/td&gt;
&lt;td&gt;legitimate (commercial, 14-day free trial)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2026-10-22&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three separate things vary across this table: how the tool looks (reading code vs probing a running service), where the tool comes from (legitimate vs attacker side), and whether an AI model drives it. If you compare tools without pulling these apart, you will credit the wrong one for the difference. We pull them apart near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attacker-side tool: six findings, four false
&lt;/h2&gt;

&lt;p&gt;HexStrike, driven by DeepSeek, chose its own next move at every step until it hit the 24-step cap we set and was cut off before finishing. It reported two critical, two high, one medium, and one low finding, for a total model bill of $0.0331.&lt;/p&gt;

&lt;p&gt;We checked each one by hand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfk3uausfbzls95gveq6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfk3uausfbzls95gveq6.png" alt="Six findings from the attacker-side tool sorted into real and false. Real: missing security headers, seen on the live response itself, and part of the logout behaviour. False: " width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is why. The code under test was an exact copy of our login routes. But the identity provider it talks to, and the audit store, were simplified stand-ins, because a sealed test environment needs something to play those parts. Our stand-in identity provider was very lenient: it handed out a token for any authorization code at all. The tool saw that and confidently reported "auth bypass, critical."&lt;/p&gt;

&lt;p&gt;That is not a flaw in our product. The lenient part was the stand-in; the real identity provider checks the code. The same thing happened with token refresh: a made-up string like &lt;code&gt;totally-made-up-xyz&lt;/code&gt; was rejected, but any string that started with &lt;code&gt;rt_&lt;/code&gt; was accepted, because the stand-in only looked at the first three characters. Three of the four false alarms came from the stand-in. The fourth came from the harness itself sending a broken request; sent properly, logout returned success.&lt;/p&gt;

&lt;p&gt;We are spelling this out because recording those four as real would mean inventing a breach that does not exist.&lt;/p&gt;

&lt;p&gt;The two real findings matter. Missing security headers can only be seen on a live response, so this was a genuine catch for the outside-in approach. The logout finding shares a root cause with something the code reader found, below.&lt;/p&gt;

&lt;p&gt;The way we wired this tool had no step that tries to disprove its own findings. It probed, saw a reaction, and reported. Without a re-check step, false alarms stay in the report alongside the real findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code reader: three findings, zero false, and proof of absence
&lt;/h2&gt;

&lt;p&gt;Strix reads the source instead of probing a running service. It ran on the same cheap model.&lt;/p&gt;

&lt;p&gt;First, the step the attacker-side tool did not have. This is Strix the day before the main run (October 19, 2026), on a trial run with a $1 cap against the same login code:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3apne80v13mi259i4r7q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3apne80v13mi259i4r7q.png" alt="The real Strix screen from a trial run on October 19, 2026, against a copy of our login code. Midway down it reads: " width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Two of the specialists' own findings did not survive my review, and I had both withdrawn." Each candidate gets an attempt to disprove it, and only what survives is kept.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzur9zrmhi9md7e6ikpv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzur9zrmhi9md7e6ikpv.png" alt="How candidates become confirmed findings. Strix raises many candidates across three areas (token and session handling, access control, secrets), tries to disprove each one, and either runs the handler against a stand-in with a control run on the un-fixed code, or traces the code path end to end. Anything it cannot reproduce or trace through the code is dropped. Three findings remained (two reproduced, one traced). Built from the run records of October 20, 2026." width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On October 20 we ran it on the four login endpoints. This is the findings file it wrote, displayed unchanged in a browser:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folx455dphq8wgj01vnoo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folx455dphq8wgj01vnoo.png" alt="The vulnerabilities.csv file written by Strix on October 20, 2026, shown unchanged. vuln-0002, audit records attribute security events to an identity taken from an unverified JWT payload: MEDIUM. vuln-0003, refresh token fragment and upstream error body written to server logs: MEDIUM. vuln-0001, logout reports success while the server-side session remains valid: INFO." width="798" height="73"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three findings, zero false alarms: two medium and one informational. These severities come from the findings file; the narrative of the same run's report calls the logout issue medium.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Logout says it succeeded, but the session on the server side is still alive (INFO).&lt;/li&gt;
&lt;li&gt;The audit log records "who did this" from a token payload that was never verified, so an anonymous caller can write any name into the audit trail (MEDIUM).&lt;/li&gt;
&lt;li&gt;When a token refresh fails, the server log gets the first 12 characters of the refresh token, its length, and the raw error from the identity provider (MEDIUM).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second and third are invisible from outside, and the outside-in tool missed them entirely. It caught part of the first, but blurred by the stand-in's behaviour.&lt;/p&gt;

&lt;p&gt;Each finding came with file, line, and either a reproduction or a traced code path. Here is the log leak:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficicmea4n5x9kifv1fhn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficicmea4n5x9kifv1fhn.png" alt="A confirmed finding. Two lines below a comment saying the token must never be logged, the refresh handler logs the first 12 characters of the refresh token, its exact length, and up to 300 characters of the upstream error body. Running the un-fixed handler against a stand-in that rejects the token produced: leaked prefix rt-SECRETV, leaked length 18, and the full token inside the leaked error body. Verdict: real, CWE-532, medium. Built from the run records of October 20, 2026." width="800" height="324"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"This could leak" became "this leaked against the stand-in, here is the output." With that much evidence, the decision is easy: fix it. The logout finding had the same treatment: across eight request shapes, seven reported a sign-out that never happened. The audit finding was shown by tracing the code path from the entry point to the audit write, and the tool limited its own claim: the forged name only lands in the audit log and is never used for an access decision, so it called it an audit-integrity problem, not an authentication bypass. Not inflated, not played down.&lt;/p&gt;

&lt;p&gt;The same run also ruled whole classes out, with reasons:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xqx71u5l7twlwpb4r59.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xqx71u5l7twlwpb4r59.png" alt="Proof of absence. SQL and command injection: ruled out, because no database client, shell, or eval exists in this code. Server-side request forgery: ruled out, because every outbound URL is a fixed setting. Hard-coded secret leak: ruled out, because the client secret stays inside outbound requests. Insecure presigned URLs: ruled out, because there is no storage-signing code in scope. Built from the run report of October 20, 2026." width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Not found yet" and "cannot happen here" are different statements. The first might just be a miss. The second lets you stop worrying about that area. Outside-in tools can say something close: ZAP ran 120 rules from outside; 117 passed, including SQL and OS command injection, and 3 raised warnings. That is useful, but it only says "we tried and nothing happened." The code reader can say why it cannot happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by side: different kinds of findings
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdoyrojukyd8qc50urm8a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdoyrojukyd8qc50urm8a.png" alt="The two AI-driven tools side by side. Logout fakes success, audit trusts an unchecked identity, and a token fragment in the log were found by the code reader (Strix); the outside-in tool (HexStrike) could not see them or saw them blurred by the stand-in. Missing security headers were found from outside and not seen by the code reader. Injection was proven absent in code and probed with nothing found from outside. False alarms: 0 vs 4 of 6. Cost: $0.45 and 9 minutes vs $0.03 and 24 steps." width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What one sees, the other cannot. Use only one kind and you will not know what you missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Legitimate scanners from outside
&lt;/h2&gt;

&lt;p&gt;That might read as "outside-in tools are noisy." So we also ran two legitimate outside-in scanners on the same copy. Neither uses AI; both walk through a fixed set of checks.&lt;/p&gt;

&lt;p&gt;ZAP, free and open source:&lt;/p&gt;

&lt;p&gt;The "False Positives 0" line counts alerts a person has marked as false; when we checked the responses ourselves, the header gaps were real.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffp20kaybead4vjf57lzw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffp20kaybead4vjf57lzw.png" alt="The real ZAP report page from October 22, 2026. Summary of alerts: High 0, Medium 0, Low 3, Informational 3, False Positives 0. The alert list shows Cross-Origin-Resource-Policy header missing, unexpected Content-Type, and X-Content-Type-Options header missing as low." width="799" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;StackHawk's scanner (HawkScan), in the terminal:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqlv13r1c1o4ibs8df3sk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqlv13r1c1o4ibs8df3sk.png" alt="HawkScan terminal output against the same copy on October 22, 2026: High 0, Medium 6, Low 13. Findings: Missing Anti-clickjacking Header, Content Security Policy header not set, X-Content-Type-Options header missing, and server leaks information via X-Powered-By (Next.js). The terminal log rendered with its original colours." width="800" height="835"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And StackHawk's dashboard:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fivpn310povmgw5e7mk31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fivpn310povmgw5e7mk31.png" alt="The real StackHawk dashboard from October 22, 2026, for the app promptflow-selftest: High 0, Medium 6, Low 13, with the four finding types listed by criticality and number of paths. Bottom left: " width="799" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 6 and 13 are counts of affected paths; by type there were four, all about response headers, all real, zero false alarms. Note the bottom left of the dashboard: StackHawk has no free-forever plan. It is a 14-day free trial, then from $10 per user per month (official pricing page, October 23, 2026). We ran inside the trial.&lt;/p&gt;

&lt;p&gt;Same outside-in approach, no confident false alarms, and they found the same kind of thing the attacker-side tool got right: the header gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling the three differences apart
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa8iwlz35ybl4vg1y3fjn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa8iwlz35ybl4vg1y3fjn.png" alt="A grid that separates the three differences. Down: how the tool looks (reads the code, probes from outside). Across: where it comes from (legitimate, attacker side). A tag on each card shows whether AI drives it. Reads the code and legitimate: Semgrep (no AI, runs in our automated daily checks, covered in Part 6) and Strix (cheap AI model, 3 real, 0 false, $0.45). Reads the code and attacker side: an empty square; an attacker usually has no copy of your code. Probes from outside and legitimate: ZAP (no AI, 0 false, free) and StackHawk (no AI, 0 false, $0 inside the 14-day trial). Probes from outside and attacker side: HexStrike (cheap AI model, 2 real, 4 false, $0.03, our setup reported without re-checking). Footer: both Strix and HexStrike ran on the same cheap model, yet only the attacker-side tool raised false alarms, so AI alone does not explain it; the visible difference was re-checking. They also faced different setups: Strix read the code, HexStrike ran against the stand-in server." width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things fall out.&lt;/p&gt;

&lt;p&gt;How a tool looks decides what it can find. Code readers found logic, audit, and logging flaws. Outside-in tools found header gaps, whether legitimate or attacker side.&lt;/p&gt;

&lt;p&gt;AI alone does not explain the false alarms. Strix and HexStrike ran on the same model. Strix had zero false alarms, HexStrike had four of six. The visible difference was the re-check step, though Strix only read code while HexStrike faced our lenient stand-in, so the setups differed too. ZAP and StackHawk, with no AI, had zero because they run fixed, disciplined checks. The zero-false-alarm results for Strix, ZAP and StackHawk come from runs against the same copy on October 20 and 22, 2026.&lt;/p&gt;

&lt;p&gt;The top-right square is empty. Attackers usually do not have your code, so they probe from outside. Reading your own code is an advantage only defenders have. Use it.&lt;/p&gt;

&lt;p&gt;Semgrep sits in the top-left as the legitimate, no-AI code reader. It already runs every day in our automated daily checks (CI). Part 6 runs it step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill was not the difference
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4adt42wgeew3fci1vdr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4adt42wgeew3fci1vdr.png" alt="What the four runs cost. ZAP: from outside, legitimate, no AI, $0, 0 false alarms. StackHawk: from outside, legitimate, no AI, $0 inside the 14-day trial, 0 false alarms. Strix: reads code, legitimate, cheap model, $0.45, 0 false alarms. HexStrike: from outside, attacker side, cheap model, $0.03, 4 of 6 false." width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of the four needed special hardware or a frontier model. That is the uncomfortable part for defenders: an attack loop like this is patience, not intelligence, so a cheap model runs it for pennies. It cuts the other way too. "Legitimate tools are too expensive" no longer holds when a solid one is free and StackHawk has a tier from $10 per user per month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three questions to sort real from noise
&lt;/h2&gt;

&lt;p&gt;The tools narrow things down, but a person makes the last call. I ask three questions, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is it actually sensitive, or actually wrong?&lt;/strong&gt; Something that looks like a key can be a public value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can it actually be exploited?&lt;/strong&gt; Theory is not enough; a real weakness lets you do something bad.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can it be reached from outside?&lt;/strong&gt; If only internal code can touch it, an attacker cannot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8q3p3jztc2lxlh5fste.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8q3p3jztc2lxlh5fste.png" alt="The three questions applied to the three confirmed findings. Logout: wrong, exploitable in 7 of 8 request shapes, reachable from outside. Audit identity: wrong, exploitable by an anonymous caller, reachable. Log leak: a real secret, reproduced, on a public route. All three pass, so all three are kept. That last judgment is the part a person still owns." width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Apply question 2 to the attacker-side tool's "auth bypass" and it fails: it only worked against our stand-in, not against a real identity provider. Most false alarms fall at that step, once you separate "what happened in the test environment" from "what would happen in the real service."&lt;/p&gt;

&lt;p&gt;Question 3 mirrors the attacker assumption from part 2: they try every door that can be reached from outside. All three real findings sit on login endpoints anyone on the internet can reach. The log leak is a partial exception: the endpoint is public, but the leaked fragment lands in the server's own log, so it bites when someone can read the logs or the logs themselves leak. It still breaks the rule never to log tokens, so we kept it. That is why medium and informational still made the fix list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take both results at face value
&lt;/h2&gt;

&lt;p&gt;Real findings are not a reason to panic. They turn "probably fine" into facts you can act on, and every one of these has a small, local fix (part 10 covers fixes). Proven absence is real reassurance. This run covered four login endpoints, and the absence was proven for those four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you collect and sort
&lt;/h2&gt;

&lt;p&gt;If we had trusted the attacker-side tool, we would believe in two critical auth bypasses that do not exist. If we had trusted only the outside-in scanners, we would have missed the forged audit trail and the token leak. Raw scanner output is not trustworthy on its own. Collect results from different kinds of tools in one place, run each through the three questions, and drop the noise. Part 9 walks through that with real screens.&lt;/p&gt;

&lt;p&gt;What to do on your own service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run both kinds: one that probes from outside (ZAP is free) and one that reads your code (Semgrep, in part 6). Do not call one of them "checked."&lt;/li&gt;
&lt;li&gt;Distrust "critical" first. Ask whether it is your code or your test environment. Four of six came from our test setup here (the stand-in server or the harness), and it can happen to you too.&lt;/li&gt;
&lt;li&gt;When choosing a tool, check whether it re-checks its own findings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 6 checks the code you wrote. We run Semgrep, the legitimate code reader from the top-left square, on our own source: install, options, the run, and how to read what comes out, one screen at a time.&lt;/p&gt;




&lt;p&gt;In this series, in an age when attack tools are free and one click away for anyone, I show step by step how to check your own service yourself and close the gaps before someone else finds them. The target is always our own service; the series does not cover attacking anyone else's site.&lt;br&gt;
If running the checks yourself is hard, EarthLink Network can do the assessment for you. Found a gap you can't fix? We'll handle the fix. Feel free to reach out.&lt;br&gt;
If this was useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>vulnerability</category>
      <category>infosec</category>
    </item>
    <item>
      <title>Testing from the outside (1): setup and the first run</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Mon, 26 Oct 2026 05:01:48 +0000</pubDate>
      <link>https://dev.to/uehara/testing-from-the-outside-1-setup-and-the-first-run-2o76</link>
      <guid>https://dev.to/uehara/testing-from-the-outside-1-setup-and-the-first-run-2o76</guid>
      <description>&lt;p&gt;This is part 4 of the series "Protect Your Own Service Yourself — Self-Defense Security in the Age of AI." Do the checks you can yourself; when it's hard, EarthLink Network can run the assessment for you, and fix the gaps you can't fix on your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Series contents&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/anyone-can-attack-now-with-ai-check-your-own-site-first-3o1h"&gt;Anyone can attack now, with AI — check your own site first&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/what-attackers-now-hold-when-ai-runs-the-attack-itself-56dk"&gt;What attackers now hold: when AI runs the attack itself&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uehara/before-you-start-the-whole-picture-of-checking-and-how-to-test-without-breaking-production-24if"&gt;Before you start: the whole picture of checking, and how to test without breaking production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Testing from the outside (1): setup and the first run (this article)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Testing from the outside (2): attack tools and legitimate scanners find different things (coming soon)&lt;/li&gt;
&lt;li&gt;Checking the code you wrote: static analysis (coming soon)&lt;/li&gt;
&lt;li&gt;Checking the parts you depend on: libraries and packages (coming soon)&lt;/li&gt;
&lt;li&gt;Checking your infrastructure settings for gaps (coming soon)&lt;/li&gt;
&lt;li&gt;Collect every result in one place and decide what to fix first (coming soon)&lt;/li&gt;
&lt;li&gt;How to fix what you found: three ways (coming soon)&lt;/li&gt;
&lt;li&gt;When you cannot fix it yourself: a maintenance contract (coming soon)&lt;/li&gt;
&lt;li&gt;Keep checking automatically every day — from building to protecting (coming soon)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;The tool a real attacker would point at other people, we pointed at a running copy of ourselves — about three cents. Alongside it we ran a legitimate AI scanner that reads a copy of our code, and two legitimate outside scanners against the same running copy; those two cost nothing this time. This is the fourth part of the series, and the first where we stop talking and actually run it: against a copy of our own service, promptflow. This article and the next are written as a manual — every command, and the screen it produces, side by side — so you can reproduce it. This part covers the setup and the first run.&lt;/p&gt;

&lt;p&gt;We line up tools of two kinds. One is a legitimate, permitted-use scanner. The other is what attackers actually run: an unrestricted tool. Think of them as the legit version and the real-attacker version of the same idea. Both are "autonomous" — they decide their own next move — and neither needs an expensive, high-end AI. We drive them with a cheap model, or run free tools.&lt;/p&gt;

&lt;p&gt;One promise up front: never point this at your live service. We ran it against a copy of our own code that never touches production. &lt;em&gt;Why&lt;/em&gt; that is, and &lt;em&gt;how far&lt;/em&gt; you may go, is what part 3 laid out.&lt;/p&gt;

&lt;p&gt;The result, first: the legitimate scanner, driven by a cheap model and scoped to the four login (authentication) routes, found &lt;strong&gt;three real weaknesses&lt;/strong&gt; in the audit and session layer — nothing critical or high, two medium and one informational. The AI bill was about &lt;strong&gt;45 cents ($0.45)&lt;/strong&gt;. We then ran the real attacker-grade tool and two legitimate outside scanners (free ZAP and commercial StackHawk) against a running copy, from outside. &lt;em&gt;What&lt;/em&gt; each one found, and how those findings differ, is next time. Here, we show how to set each up and run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why use an AI scanner
&lt;/h2&gt;

&lt;p&gt;A person can do this by hand. But there are two clear reasons to do it with an AI tool now.&lt;/p&gt;

&lt;p&gt;One is speed and reach. promptflow exposes 231 entry points (APIs) to the outside. Checking each by hand would take days — and the attacking side never stops, automatically. The defender working by hand cannot keep up. An AI tool decides on its own which entry points to check and in what order, and sweeps them at once. If attackers let AI do the planning, defenders should let the same AI do the planning. That is what "Defenders have the same AI too" from part 2 looks like in practice.&lt;/p&gt;

&lt;p&gt;The other is covering more ground than one person can hold in their head. A modern web service is tangled together with many external components and connected services. Even if your own code is perfect, a weak component or a weak downstream service becomes the way in. No person can trace that whole tangle anymore, which is why letting a tool look broadly became the realistic way.&lt;/p&gt;

&lt;p&gt;From here on, the actual procedure. First we run one legitimate scanner end to end, written so you can reproduce it step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Install the scanner
&lt;/h2&gt;

&lt;p&gt;The first tool is Strix, an open-source AI autonomous scanner — the defender-side, legitimate version of the kind of autonomous tool attackers use, and one that part 2 described. Installing it is one command; no special machine is needed. It runs on the computer you already use.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxrtb0xocacr5bd570wfe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxrtb0xocacr5bd570wfe.png" alt="Step 1: install the scanner. Running  raw `uv tool install strix-agent` endraw  installs an executable called  raw `strix` endraw ;  raw `strix --version` endraw  confirms the version (1.7.0). The scan itself runs inside an isolated Docker container (a sealed-off box created inside your computer; Docker is the software that runs it), so keep Docker running." width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;strix --version&lt;/code&gt; prints a version number, you are set. If you see &lt;code&gt;command not found&lt;/code&gt;, the install path isn't on your PATH (the list of folders your computer searches for commands): find it with &lt;code&gt;which strix&lt;/code&gt; and add that location. The scan runs inside an isolated Docker container (a sealed-off box created inside your computer; Docker is the software that runs it), so start Docker first — otherwise the run stalls the moment it tries to begin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Point it at a cheap model
&lt;/h2&gt;

&lt;p&gt;The tool runs one AI model at its center — the same mechanism attackers use to let AI do the planning. The key here is &lt;strong&gt;not to pick an expensive, high-end model&lt;/strong&gt;. Security checking is less about hard reasoning and more about patiently trying each entry point, one after another. A cheap model doing volume is closer to what attackers actually do. We chose DeepSeek, a cheap model, and set its API key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmu6yai9crg4ry45u21z2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmu6yai9crg4ry45u21z2.png" alt="Step 2: point it at a cheap model. Environment variables (settings passed to a command) set the model (deepseek-chat), the endpoint, and the API key. Read the balance before the run (here $6.59); read it again afterwards to learn the real cost." width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One easy stumble: if the way the tool connects to the model is misconfigured, it stops before the scan begins with "can't connect." That stopped me at first. Specifying the cheap model through an OpenAI-compatible connection, with the endpoint (API address) stated explicitly, made it run. If it won't start, first check the key and the endpoint address. And note the balance before you run: read it again afterward and the difference is the exact cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Scan a copy, never production
&lt;/h2&gt;

&lt;p&gt;Per part 3's line: the target is not production. Make a copy of your own code and point the scan at that. Whatever the tool does to a copy, production is untouched.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmuon68guorg39ki77pho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmuon68guorg39ki77pho.png" alt="Step 3: scan a copy, never production.  raw `rsync` endraw  copies your own code somewhere else (production is never touched). Then scope the scan to just the authentication (auth) routes — there are four here: login-token exchange (a token is the pass the service hands out after you sign in), logout, current-user, and refresh. The narrower the scope, the smaller the cost and the deeper the check." width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once copied, narrow the scope. The whole service has 231 entry points, but we scoped to just the four authentication routes — login-token exchange, logout, current-user, refresh. Narrowing the scope means even a small budget reaches past the surface, into checking each one deeply. "Narrow and deep" beats "broad and shallow" for a first step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Run it, with a hard cost cap
&lt;/h2&gt;

&lt;p&gt;With the prep done, state the target and what to look for, and run. The single most important thing here is to &lt;strong&gt;pass a cost cap&lt;/strong&gt;. Overspend and it stops automatically. Without it, the bill can surprise you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe5kqqwcirvd2naleanlr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe5kqqwcirvd2naleanlr.png" alt="Step 4: the command to run.  raw `-t` endraw  sets the target (the copy, scoped to auth),  raw `-m quick` endraw  a fast first pass,  raw `--max-budget 1.0` endraw  the cost cap ($1.00, then auto-stop),  raw `-n` endraw  runs it to the end on its own, and  raw `--instruction` endraw  tells it: " width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The instruction, in one line: "this is an authorized self-test of our own code; report findings in a form that shows which file and where." Not "carry out attacks," but "find and report weaknesses." The cap this time was $1.00. It never got there — it finished on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — What you see when it starts
&lt;/h2&gt;

&lt;p&gt;On run, an isolated environment (a Docker container) comes up, the scanning skills load, and the AI starts planning the check itself. Without a human saying "check this next," it reads the previous result and decides the next move on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fka0y1l91xylkazk2lguo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fka0y1l91xylkazk2lguo.png" alt="Step 5: the startup screen. An isolated Docker container comes up, 10 skills are loaded, and a " width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Among the skills it loads is one called &lt;code&gt;counterevidence&lt;/code&gt;. When it finds "this entry point might be dangerous," this skill makes it go looking for the opposite evidence, to verify whether the danger is real. That mechanism is what later produces the "doesn't call things out and walk away" behavior. In part 2, the attacker's tool planned its own steps; here the legitimate version does the same thing, against our own service. The way it works — the AI planning its own steps — is the same as the attacker's tool. The difference: the thing being checked is ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Where it stopped, and the real cost
&lt;/h2&gt;

&lt;p&gt;After a while, the AI finishes on its own. Then compare the run record with the balance you noted in step 2 to learn the real cost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgmtw7ictjuue05o7i4go.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgmtw7ictjuue05o7i4go.png" alt="Step 6: where it stopped, and the real cost. The record reads " width="799" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The real cost was $0.45. 235 exchanges, about 9 minutes. An earlier run with an expensive model (gpt-4.1) on the whole app — all 231 entry points — hit its $3 cap after spending $3.04, never got past the survey stage, and confirmed nothing. Narrowed to the four auth routes and switched to a cheap model, this run finished at $0.45 and found three real weaknesses. "You need an expensive model to run checks" is not true. For this kind of work, a cheap model doing volume fits better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — Three real weaknesses
&lt;/h2&gt;

&lt;p&gt;The scan found &lt;strong&gt;three real weaknesses&lt;/strong&gt; across the four authentication routes. None is critical or high — two medium, one informational — nothing that lets the service be taken over on the spot. But each cracks one of the two foundations authentication must guarantee: a trustworthy audit trail (the record of who did what), and a session (the logged-in state) that actually ends when told to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flecx1t8kqeh62wv3xlcw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flecx1t8kqeh62wv3xlcw.png" alt="Step 7: the three weaknesses found. (1) Logout reports " width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In plain terms:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Logout says "success" without confirming the login ended&lt;/strong&gt; (informational). The logout route returns "success" and records "session destroyed" without ever asking the sign-in service to end the login — or after that service refused or could not be reached. Seven of the eight request shapes tried did this. The machine rated it informational, the mildest level. Still, it's the seed of a real danger: someone who already holds a copy of the refresh token can keep minting new login tokens after the user believes they logged out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audit "who" is trusted without checking&lt;/strong&gt; (medium). The "who did it" written into the record is read from a token whose signature was never verified — meaning even a caller who is not logged in can plant any name into the record. The record stops being usable as evidence later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fragment of the login token leaks into the log&lt;/strong&gt; (medium). On a failed token refresh, the first twelve characters and the length of the submitted token, plus the upstream error text, are written to the log. The tool judged that more people can read logs than can read the token store, and rated it a leak path — and two lines above sits a policy rule: the token and the error text must never be put in the response to the user. The response side obeys it; only the logging side does not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What matters is that the machine did not stop at "might have found something": using the &lt;code&gt;counterevidence&lt;/code&gt; skill it re-tested each candidate and narrowed them to the three that actually happen. For the logout and token-fragment findings, it ran the un-fixed code inside Strix's isolated container, with the services it talks to and the places it writes records swapped for stand-ins, to confirm they really occur; the audit finding it showed by tracing the code. That is where it differs from the old kind of check that only lists warnings. Each of the three comes with the file and line, how to reproduce it, and how to fix it.&lt;/p&gt;

&lt;p&gt;It also proved, with evidence, that certain whole classes of danger — injection (slipping database or shell commands into input), and SSRF (tricking the server into calling somewhere it shouldn't) — are &lt;em&gt;not&lt;/em&gt; present in this scope. That "proof of absence" is as valuable as the findings, but how to read it, and why it's worth so much, is something we'll cover properly next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the outside tools — a running copy
&lt;/h2&gt;

&lt;p&gt;So far, the legitimate tool &lt;strong&gt;read the copied code&lt;/strong&gt; to find weaknesses. Next comes what attackers actually run: the real-grade tool — HexStrike, which we named in part 3. It does not read code. It &lt;strong&gt;probes the running service from outside, like a stranger&lt;/strong&gt;. For that, the service being checked has to be actually running.&lt;/p&gt;

&lt;p&gt;But running doesn't mean production. On a private network that nothing outside can reach into, we bring up a throwaway copy of just the service's login routes, and point every test at that copy only.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5gzu0zr9f30kiocqejj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5gzu0zr9f30kiocqejj.png" alt="Setup: for the outside tools, bring up a running copy on a private network that nothing outside can reach into.  raw `docker compose up` endraw  starts three things: the service under test (an exact copy of the four login routes), a simplified stand-in sign-in server, and the attacker (an attack tool driven by a cheap AI). No port is opened to the outside and every test points at this copy, so production and third parties are never touched. The legitimate outside scanners (ZAP and StackHawk) later point at the same target." width="799" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things run on that private network: the service under test (a copy of our own login routes), a stand-in that handles the sign-in check, and the attacking tool. The only target is this throwaway copy — never production, never anyone else's site. This series is only ever about checking ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outside tool picks its own moves
&lt;/h2&gt;

&lt;p&gt;First, the real-grade attacker tool. What's striking is that you hand it only the address of the service. From there, the cheap AI decides "what to try next" by itself. No human writes the steps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0qmc1shxqa29bs7si6vx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0qmc1shxqa29bs7si6vx.png" alt="The outside tool, driven by a cheap AI deciding its own moves. Given only the address, the AI decides each next step. Step 1 " value="" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The left number is the step; the right number is the cost so far, calculated from the AI usage. At step 1 the AI decided "first confirm the service is up and look at the response headers," and chose its own probing tool. At step 4 it noted that any value returns a token, so this looks like a way in, and at step 5 recorded it as a serious candidate. It ran to step 24, hit the step limit, and stopped. It raised 6 candidates in all. The cost: about 3 cents, calculated from usage — the account balance did not move by even a cent. Not a high-end, latest AI — just a cheap one, run many times.&lt;/p&gt;

&lt;p&gt;One important caveat: those 6 are still &lt;em&gt;candidates&lt;/em&gt;. Run a cheap AI many times and, mixed in with the real ones, it also produces confident mistakes. In fact, some of the candidates turned out to come not from our own code but from the stand-in server or the way the test was run. Sorting real from mistaken, one by one, is exactly what we show next time. For now, watch the run itself: hand it an address, and a cheap AI advances its own moves and raises candidates for a few cents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What only the outside view caught
&lt;/h2&gt;

&lt;p&gt;Among the candidates was one thing &lt;strong&gt;a code-reading tool could never find&lt;/strong&gt;: the safety settings that should be attached to the response (security headers) were missing entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8tm9apouqpxhh1lhg5a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8tm9apouqpxhh1lhg5a.png" alt="What only the outside view caught. A tool that inspects live responses (nuclei) detected that the safety headers are all absent. The settings that prevent data exfiltration, force encrypted transport, and block display hijacking are all missing from the response. Even the name of the underlying framework (Next.js) is exposed in the response. A tool that only reads code cannot report this, because it never sees a live response." width="799" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These headers tell the browser small promises like "don't let this display be embedded in other sites" and "always encrypt the connection." Missing, they open the way to display hijacking or eavesdropping. They don't appear in the code — they live on the "outside" of a running service — so you can't tell they're missing without receiving an actual response. A code-reading tool never sees a live response, so it cannot, in principle, find this. A genuine catch for the outside view.&lt;/p&gt;

&lt;p&gt;What's "found by probing from outside" and what's "found by reading code" differ structurally. Setting that difference side by side is one of the main subjects of next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Legitimate tools can also test from outside
&lt;/h2&gt;

&lt;p&gt;So far, the outside probing used an attacker's tool. But probing from outside is something legitimate, permitted-use tools can do too — and you can start for free. We ran two legitimate outside scanners against the same running copy.&lt;/p&gt;

&lt;p&gt;The first is ZAP, a free tool anyone can use.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq2hb2ioj56m3hts18tpp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq2hb2ioj56m3hts18tpp.png" alt="Legitimate outside scanner (free): the OWASP ZAP run log.  raw `docker run` endraw  starts ZAP, which reads the list of the service's entry points (the OpenAPI spec) and works through its check items one by one. After " width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You give ZAP the list of entry points (the OpenAPI spec). From that list, it works through a fixed set of checks, one at a time, against the running copy. After "Imported 8 URLs / 25 total," it runs injection, OS command injection, attacks that abuse XML, exposure of cloud settings, and so on, in turn — about 120 checks in all (117 passed). What came out, as a tally, is a result screen, so it's next time. Here, watch the run: a free tool, testing the same copy from outside, in a disciplined sweep.&lt;/p&gt;

&lt;p&gt;The second is StackHawk, a commercial tool for companies. It has no permanent free plan, but the first 14 days are free to try; we used it within that window.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7yc4l6zsupy08dcvpetf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7yc4l6zsupy08dcvpetf.png" alt="Legitimate outside scanner (commercial): the StackHawk (HawkScan) run log.  raw `docker run` endraw  starts it; it crawls the service's entry points to build a list (17 URLs discovered), then runs the actual scan. It progresses through " width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;StackHawk points at the same running copy with the same list of entry points. It first crawls the entry points to build a list (it found 17 URLs), then runs the actual scan. It progresses through "configuring scan engine," "spider complete," "active scan started and complete," and sends what it found to a management screen. Here too, the tally is next time.&lt;/p&gt;

&lt;p&gt;So this time we ran four tools against copies of our own service: a legitimate scanner that reads code, a real-grade attacker tool that probes from outside, and two legitimate outside scanners (free ZAP and commercial StackHawk). None needs a special machine or an expensive, high-end AI. That is the setup and the first run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Next time we set side by side &lt;strong&gt;what each of these four tools actually found&lt;/strong&gt;. Outside probing and code reading find structurally different kinds of weakness — each sees things the other can't. And of the candidates the cheap-model attacker tool raised, which were real and which were confident mistakes? How many false alarms did the legitimate tools produce? What did each cost, and does "legit means expensive" still hold? We'll show the substance, in the actual result screens.&lt;/p&gt;




&lt;p&gt;In this series, in an age when attack tools are free and one click away for anyone, I show step by step how to check your own service yourself and close the gaps before someone else finds them. The target is always our own service; the series does not cover attacking anyone else's site.&lt;br&gt;
If running the checks yourself is hard, EarthLink Network can do the assessment for you. Found a gap you can't fix? We'll handle the fix. Feel free to reach out.&lt;br&gt;
If this was useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>One dotvault pull, the same .env everywhere: laptop, CI, cloud dev environments</title>
      <dc:creator>uehara</dc:creator>
      <pubDate>Sun, 25 Oct 2026 01:29:54 +0000</pubDate>
      <link>https://dev.to/uehara/one-dotvault-pull-the-same-env-everywhere-laptop-ci-cloud-dev-environments-aj2</link>
      <guid>https://dev.to/uehara/one-dotvault-pull-the-same-env-everywhere-laptop-ci-cloud-dev-environments-aj2</guid>
      <description>&lt;p&gt;This is part 2 of the series "dotvault, Start to Finish: One Command from Your Laptop to CI, Your Team, and Your AI Agents." dotvault will be released free of charge when this series ends.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; rarely lives in one place. The same variables are needed in CI, on the server that runs the app, and in cloud development environments such as GitHub Codespaces, Codex cloud in ChatGPT and Claude Code on the web. With dotvault, every one of those places gets its values the same way: &lt;code&gt;dotvault pull&lt;/code&gt;, one command. Install, login and init happen once per place. After that it is pull, or &lt;code&gt;run&lt;/code&gt; if you want no &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Any environment that has Node.js and can reach the internet over HTTPS runs the same steps. The terminal screens in this part come from a fresh &lt;code&gt;node:20&lt;/code&gt; Docker container with nothing carried over from my own machine. This is part 2 of 12, run on October 18, 2026 against the production console at app.dotvault.io and the latest published CLI (0.15.7). Every step has its screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One command per place: &lt;code&gt;dotvault pull&lt;/code&gt;.&lt;/strong&gt; Install, login and init are once per place. If you commit the &lt;code&gt;project-config.json&lt;/code&gt; that &lt;code&gt;init&lt;/code&gt; writes, a fresh clone does not even need &lt;code&gt;init&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix a value once, in the console.&lt;/strong&gt; Every place picks it up on its next pull. With &lt;code&gt;run&lt;/code&gt;, the next start already uses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node.js plus outbound HTTPS is all it needs.&lt;/strong&gt; This part was done in a fresh Linux container with a keychain (gnome-keyring and libsecret) added. Where there is no OS keychain, the CLI stores the token in a mode-600 file instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dotvault pull&lt;/code&gt; rewrites one marked block inside &lt;code&gt;.env&lt;/code&gt; and leaves alone your own lines for keys dotvault does not hold.&lt;/strong&gt; The file is written with mode 600, so only your user can read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dotvault run -- &amp;lt;command&amp;gt;&lt;/code&gt; skips the &lt;code&gt;.env&lt;/code&gt;.&lt;/strong&gt; The values from this part go into that one process without being written to a &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Places where no human logs in (CI, servers, AI agents working on their own) still run the same &lt;code&gt;dotvault pull&lt;/code&gt; or &lt;code&gt;run&lt;/code&gt;.&lt;/strong&gt; The only difference is a service token instead of a login. Parts 7 and 8.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the same .env has to exist
&lt;/h2&gt;

&lt;p&gt;The values live in one dotvault project. Only the way each place receives them differs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Place&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;How it gets the values&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your machine&lt;/td&gt;
&lt;td&gt;Mac, Windows, Linux&lt;/td&gt;
&lt;td&gt;Log in, then pull or run (this part)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud dev environment with a human at the terminal&lt;/td&gt;
&lt;td&gt;GitHub Codespaces&lt;/td&gt;
&lt;td&gt;Exactly as on your machine (this part)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud dev environment where an AI agent works on its own&lt;/td&gt;
&lt;td&gt;Codex cloud in ChatGPT, Claude Code on the web&lt;/td&gt;
&lt;td&gt;Service token, then the same pull or run (issuing and passing the token: parts 7 and 8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;GitHub Actions and the like&lt;/td&gt;
&lt;td&gt;Service token, then the same pull or run (part 7)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server runtime&lt;/td&gt;
&lt;td&gt;The server or container that runs the app&lt;/td&gt;
&lt;td&gt;Service token, then the same pull or run (issuing and passing the token: part 7)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Once per place, and every time after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm i -g @earthlink/dotvault&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Once per place&lt;/td&gt;
&lt;td&gt;Installs the CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dotvault login&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Once per place (again only when the refresh token expires; see step 2)&lt;/td&gt;
&lt;td&gt;Logs in with email and password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dotvault init&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Once per project folder (not needed in a clone once committed)&lt;/td&gt;
&lt;td&gt;Binds the folder to a project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dotvault pull&lt;/code&gt; / &lt;code&gt;dotvault run -- &amp;lt;command&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every time you need the values&lt;/td&gt;
&lt;td&gt;Writes the &lt;code&gt;.env&lt;/code&gt; / starts a command with the values&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Creating the project and its keys in the console is also once per project. The rest of this part walks through the first time, in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you read on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who this is for.&lt;/strong&gt; Developers, freelancers and small teams who are still keeping the same &lt;code&gt;.env&lt;/code&gt; in several places by hand. You do not need to have read part 1 to finish this one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date and environment.&lt;/strong&gt; Screens are from October 18, 2026: the production console and CLI 0.15.7, the latest that day. &lt;code&gt;npm i -g&lt;/code&gt; now installs 0.15.8; on October 23 I ran the same steps on 0.15.8, and the differences are the version number, the marker line written into &lt;code&gt;.env&lt;/code&gt; (&lt;code&gt;# BEGIN: dotvault (managed by dotvault)&lt;/code&gt;), the wording in brackets of the network error, and that a hand-written line for a key pull also writes is now commented out (see step 5), within what the steps of this part show (key and file counts are higher now because later parts add keys, files and Global Keys). The terminal is a Linux container (Docker &lt;code&gt;node:20&lt;/code&gt;) with no earlier configuration and a keychain (gnome-keyring and libsecret) added. On macOS the commands are the same (the screens in this part were not taken on macOS; the first login may show a dialog asking to allow Keychain access).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal screens are recomposed.&lt;/strong&gt; Raw terminal screenshots come out too small to read, so the terminal figures reproduce the actual output verbatim in a cleaner frame. The raw output is kept as is. Exit codes, recorded separately, are shown as an &lt;code&gt;echo $?&lt;/code&gt; line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo account.&lt;/strong&gt; The email address, project name and values in the screens were created for this series. None of the values are real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary.&lt;/strong&gt; "Secret" means any value that must not leak: API keys, passwords, connection strings. A &lt;code&gt;.env&lt;/code&gt; file is those secrets as &lt;code&gt;KEY=VALUE&lt;/code&gt; lines. Part 1 has the full glossary.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Install (once per place)
&lt;/h2&gt;

&lt;p&gt;Node.js 20 or later is the only requirement, on your machine or in a cloud dev environment's terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; @earthlink/dotvault
dotvault &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkogkurxlb6nq2pmq4xf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkogkurxlb6nq2pmq4xf.png" alt="Terminal (recomposed, October 18, 2026):  raw `npm i -g @earthlink/dotvault` endraw  in a Node 20 container prints one npm deprecation warning about a dependency, then " width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;npm prints one deprecation warning for a dependency. The install still completes. If &lt;code&gt;dotvault --version&lt;/code&gt; prints a version number, you are ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Log in (once per place)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dotvault login&lt;/code&gt; asks for Email, then Password. The password is not echoed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotvault login
dotvault whoami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb75iizzzietg0wwlv1ng.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb75iizzzietg0wwlv1ng.png" alt="Terminal (recomposed, October 18, 2026):  raw `dotvault login` endraw  prompts for Email and Password and ends with " width="800" height="294"&gt;&lt;/a&gt;. Token expires in 3600s. Refresh token saved."; `dotvault whoami` prints the email, the token expiry and "org: org_personal_… Personal (personal, org_admin)"."/&amp;gt;&lt;/p&gt;

&lt;p&gt;The token goes into the OS keychain (Keychain on macOS), not into a plain-text file. "Token expires in 3600s" sounds short, but a refresh token is saved next to it, so you are not logging in every hour. The refresh token lasts 30 days; when it runs out, run &lt;code&gt;dotvault login&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dotvault whoami&lt;/code&gt; shows your email and the organisation you are in. At this point a Personal organisation already exists for you; it was created on your first login. Nobody had to set it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Create a project and three keys in the console (once per project)
&lt;/h2&gt;

&lt;p&gt;The contents of the &lt;code&gt;.env&lt;/code&gt; are prepared in the browser console at app.dotvault.io, with the same email and password. In a Personal organisation the sidebar shows eight items under a PERSONAL heading. The team features (Teams, My Organizations) are not there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn85a16bjx0wqutht8bp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn85a16bjx0wqutht8bp.png" alt="Console sidebar in a Personal organisation (English UI): Dashboard, Members, Projects, Global Keys, Service Tokens, Audit Log, Help and User Settings under the PERSONAL heading; no Teams, no My Organizations." width="482" height="1800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open Projects and press New project. The modal asks for one thing, a name: lowercase letters, digits, hyphens and underscores, starting with a letter or digit. I used &lt;code&gt;demo-app&lt;/code&gt;. Project names are unique across dotvault, so &lt;code&gt;demo-app&lt;/code&gt; is taken; pick your own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8ezhhq2bc09d4is091k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8ezhhq2bc09d4is091k.png" alt="Create project modal (English UI, October 18, 2026): one Project name field, the rule " start="" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After Create, the list shows &lt;code&gt;demo-app&lt;/code&gt; with three environments already attached: development, staging, production. Part 3 is about those. This part only uses development.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2tovplql0yfnhx2pom2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2tovplql0yfnhx2pom2.png" alt="Projects list (English UI): the demo-app row with development, staging and production, plus Settings and Access links; an Active only filter and " width="800" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;demo-app&lt;/code&gt;. The Keys tab has development selected and no keys yet, so it says "No keys in development yet." The Selected key panel on the right says "Click a key on the left to inspect."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc03yfpr5brbe84dc1bz8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc03yfpr5brbe84dc1bz8.png" alt="demo-app Keys tab (English UI, October 18, 2026): environment switch (development, staging, production, Common (_shared)), a Filter keys box, + Add key / + Add file / ↑ Upload buttons, the empty state " width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Press + Add key and an inline row appears with Key name and Value. Fill both, press Add. Three times for &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;API_BASE_URL&lt;/code&gt; and &lt;code&gt;SMTP_PASSWORD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtym3b0worbg2c3gvtj2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtym3b0worbg2c3gvtj2.png" alt="Keys tab after pressing + Add key (English UI): an inline row with Key name and Value filled with SMTP_PASSWORD and a demo value, Add and Cancel below it, and the earlier DATABASE_URL row underneath." width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click a key name and the Selected key panel shows its path (development/env/DATABASE_URL), a description field, a reveal button, delete, and a button that moves the key to Common, a shared place for keys that every environment can use. Common is covered in part 3. Values are not shown in the list. They appear only when you press the reveal button. The button says "audit-logged"; what the audit record actually holds is checked in part 10.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzwg64ck08u80p9dhlb1v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzwg64ck08u80p9dhlb1v.png" alt="Keys tab with DATABASE_URL selected (English UI, October 18, 2026): three rows API_BASE_URL, DATABASE_URL, SMTP_PASSWORD, each with a lock icon and an updated timestamp; the Selected key panel shows development/env/DATABASE_URL, Description with Save description, Reveal value (audit-logged), Delete and ⬆ Promote to Common (_shared)." width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is all the console work. When a value changes later, this is where you change it. Back to the terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Bind the folder to the project (init, once)
&lt;/h2&gt;

&lt;p&gt;In the project folder, &lt;code&gt;dotvault init&lt;/code&gt; writes one file, &lt;code&gt;project-config.json&lt;/code&gt;. The project name defaults to the folder name; &lt;code&gt;dotvault init --name &amp;lt;name&amp;gt;&lt;/code&gt; overrides it.&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="nb"&gt;cd &lt;/span&gt;demo-app
dotvault init
&lt;span class="nb"&gt;cat &lt;/span&gt;project-config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0e3q8khnsj5csixokhx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0e3q8khnsj5csixokhx.png" alt="Terminal (recomposed, October 18, 2026):  raw `dotvault init` endraw  prints " width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are no secrets in this file: the project name, the API URL, the list of files to write, the default environment. Commit it. Any clone, in CI or in a cloud dev environment, can then pull without running &lt;code&gt;init&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Pull the .env (every time)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dotvault pull&lt;/code&gt; runs three stages on one progress bar, authenticating, fetching, writing, and finishes with the number of keys written.&lt;/p&gt;

&lt;p&gt;Before pulling, I put a few hand-written lines in &lt;code&gt;.env&lt;/code&gt; (a comment, &lt;code&gt;PORT&lt;/code&gt; and &lt;code&gt;DEBUG&lt;/code&gt;) to show what happens to them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotvault pull
&lt;span class="nb"&gt;cat&lt;/span&gt; .env
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4owwqtq7u1aaamb87mnv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4owwqtq7u1aaamb87mnv.png" alt="Terminal (recomposed, October 18, 2026):  raw `cat .env` endraw  shows three hand-written lines (a comment, PORT=3000, DEBUG=app:*);  raw `dotvault pull` endraw  shows the progress bar, then " width="799" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things to notice.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your lines survive.&lt;/strong&gt; dotvault only writes the block between &lt;code&gt;# BEGIN:&lt;/code&gt; and &lt;code&gt;# END:&lt;/code&gt;. The next pull replaces that block. That is what "Markers preserved." means. On 0.15.8, the current version, a line outside the block for a key that pull also writes (say a hand-kept &lt;code&gt;DATABASE_URL=&lt;/code&gt;) is turned into a comment starting with &lt;code&gt;# dotvault: moved into the managed block below:&lt;/code&gt;, and pull prints a warning. Nothing is deleted; the value inside the block is the one that applies. When you move a hand-kept &lt;code&gt;.env&lt;/code&gt; to dotvault, that warning tells you which keys overlapped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file is mode 600.&lt;/strong&gt; &lt;code&gt;-rw-------&lt;/code&gt; means your user can read and write it and no other user on the machine can read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The keys added in this part come out sorted by name,&lt;/strong&gt; not in the order you added them in the console.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After you change a value in the console, the same &lt;code&gt;dotvault pull&lt;/code&gt; in any place replaces the marked block with the current values.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Run without a .env (run, every time)
&lt;/h2&gt;

&lt;p&gt;If you would rather not have a &lt;code&gt;.env&lt;/code&gt; on disk, &lt;code&gt;dotvault run -- &amp;lt;command&amp;gt;&lt;/code&gt; puts the variables from this part into that one process's environment without writing a &lt;code&gt;.env&lt;/code&gt;. One exception: if the project holds whole-file secrets (such as &lt;code&gt;.npmrc&lt;/code&gt;, covered in part 5), run writes those files to disk while the command runs. They are removed when the command ends normally, but can be left behind if you stop dotvault itself, for example with Ctrl-C. Part 5 covers this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotvault run &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;printenv &lt;/span&gt;DATABASE_URL
dotvault run &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "$API_BASE_URL"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2edtuof5l1p6qo5nojob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2edtuof5l1p6qo5nojob.png" alt="Terminal (recomposed, October 18, 2026): both  raw `dotvault run -- printenv DATABASE_URL` endraw  and  raw `dotvault run -- sh -c 'echo " value="" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When to use which:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;dotvault pull&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;dotvault run -- &amp;lt;command&amp;gt;&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; on disk&lt;/td&gt;
&lt;td&gt;Yes (the marked block only)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who sees the values&lt;/td&gt;
&lt;td&gt;Anything that reads the file&lt;/td&gt;
&lt;td&gt;That one process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fits&lt;/td&gt;
&lt;td&gt;Tools and frameworks that expect a &lt;code&gt;.env&lt;/code&gt;, start scripts written by other people&lt;/td&gt;
&lt;td&gt;Local development (&lt;code&gt;dotvault run -- npm run dev&lt;/code&gt;), CI, anywhere you do not want a plain-text &lt;code&gt;.env&lt;/code&gt; on disk (whole-file secrets: part 5)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After a value changes&lt;/td&gt;
&lt;td&gt;Pull again&lt;/td&gt;
&lt;td&gt;The next start uses the new value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For local development, use run. If there is no plain-text &lt;code&gt;.env&lt;/code&gt;, it cannot be committed by accident and it does not end up in a backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  It works without init too
&lt;/h2&gt;

&lt;p&gt;In a folder with no &lt;code&gt;project-config.json&lt;/code&gt;, pull and run still work if you name the project. A bare &lt;code&gt;dotvault list&lt;/code&gt; shows the projects you can read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotvault list demo-app
dotvault pull demo-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflkucmit81jnw360s60r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflkucmit81jnw360s60r.png" alt="Terminal (recomposed, October 18, 2026), in a folder that is not a project:  raw `dotvault list demo-app` endraw  prints project=demo-app and a table with env, file, keys and last_modified (development, .env, 3, a timestamp);  raw `dotvault pull demo-app` endraw  prints " width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Putting the project name straight after the command (&lt;code&gt;dotvault pull demo-app&lt;/code&gt;) and &lt;code&gt;--project demo-app&lt;/code&gt; mean the same thing. &lt;code&gt;init&lt;/code&gt; is only for pinning a folder to a project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a failure
&lt;/h2&gt;

&lt;p&gt;A failure prints &lt;code&gt;✖&lt;/code&gt;, a code in capitals, and sets a fixed exit code (&lt;code&gt;echo $?&lt;/code&gt;). Scripts can branch on the exit code. The table lists the causes seen in this part and what to check first; the same code can have other causes too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe097nq54p8cocgrjf2ap.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe097nq54p8cocgrjf2ap.png" alt="Terminal (recomposed, October 18, 2026) with five failures:  raw `dotvault pull` endraw  in a folder without config → ✖ VALIDATION_ERROR (exit 6); staging named but holding no keys → ✖ PROJECT_NOT_FOUND (5);  raw `Demo-App` endraw  with capitals → ✖ BAD_PROJECT (1); API URL pointed at an unreachable address → ✖ NETWORK_ERROR (2); pull after logout → ✖ AUTH_REQUIRED (3)." width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;code&lt;/th&gt;
&lt;th&gt;exit&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUTH_REQUIRED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Not logged in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dotvault login&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PERMISSION_DENIED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;No permission on that project or environment, or no project by that name&lt;/td&gt;
&lt;td&gt;First check the project name for typos: a project name that does not exist also returns this code. If the name is right, ask an org admin to grant access in the console. A service token also gets it when it reads an environment or project it was not allowed (part 7). Permissions are covered in part 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PROJECT_NOT_FOUND&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;No secrets for that project and environment (here: staging, which had no keys yet)&lt;/td&gt;
&lt;td&gt;Check your project's name with &lt;code&gt;dotvault list&lt;/code&gt;, and in the console whether that environment has keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VALIDATION_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;project-config.json&lt;/code&gt;, or something missing from the command&lt;/td&gt;
&lt;td&gt;Name the project, or run &lt;code&gt;dotvault init&lt;/code&gt; in that folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NETWORK_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;The API URL cannot be reached&lt;/td&gt;
&lt;td&gt;Check the connection and &lt;code&gt;apiUrl&lt;/code&gt; in &lt;code&gt;project-config.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BAD_PROJECT&lt;/code&gt; / &lt;code&gt;BAD_ENV&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Project name in the wrong format (capitals), or an environment the project does not have&lt;/td&gt;
&lt;td&gt;Use a lowercase name; use development, staging, production, or an environment you add in part 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;PROJECT_NOT_FOUND&lt;/code&gt; also appears when the project exists but that environment has no keys at all. The second failure in the screen is exactly that: staging, with nothing in it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same steps on Linux and in containers without a keychain
&lt;/h2&gt;

&lt;p&gt;Where the OS keychain cannot be used (Linux without libsecret, or a headless container where libsecret is installed but no keychain service is running), the CLI prints a two-line notice and stores the token in &lt;code&gt;~/.config/dotvault/credentials.json&lt;/code&gt; with mode 600. The commands and the behaviour are the same. The screen below is the same container run without its keychain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffr1z1i1bcfgwvmfvfufu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffr1z1i1bcfgwvmfvfufu.png" alt="Terminal (recomposed, October 18, 2026):  raw `dotvault whoami` endraw  in the same Linux container, run outside its keychain service (no D-Bus session), prints " width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix: your account
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Forgotten password
&lt;/h3&gt;

&lt;p&gt;From "Forgot your password?" on the login screen, enter your email to receive a confirmation code, then enter the code and the new password on the next screen. Passwords need at least 12 characters, with upper and lower case, a digit and a symbol. The form says 8, but the server rule, checked on October 23, 2026, is 12.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6wh8trtswo2llf0xuhd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr6wh8trtswo2llf0xuhd.png" alt="/forgot-password (English UI, October 18, 2026): " width="800" height="557"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frd2ivh2js8q7t38cbshw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frd2ivh2js8q7t38cbshw.png" alt="/forgot-password/confirm (English UI, October 18, 2026): " width="800" height="910"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Display language
&lt;/h3&gt;

&lt;p&gt;User Settings in the sidebar has a Language select. Choosing one saves immediately and shows "Saved." The setting lives in the browser, not on the account, so another browser starts from its own default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxdltegjjezfbphda44qq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxdltegjjezfbphda44qq.png" alt="User Settings (English UI, October 18, 2026): the note " width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Logging out
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;dotvault logout&lt;/code&gt; removes the stored token of the account currently in use from this machine (the keychain, or the file where there is no keychain). If no other account is logged in on this machine, &lt;code&gt;dotvault whoami&lt;/code&gt; then says "Not logged in." If you also logged in with another account, that one is used after logout, so log out of it as well.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1yaxhbl56lqi44gqv8hd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1yaxhbl56lqi44gqv8hd.png" alt="Terminal (recomposed, October 18, 2026):  raw `dotvault logout` endraw  prints " width="800" height="294"&gt;&lt;/a&gt;."; `dotvault whoami` prints "Not logged in." with exit code 3."/&amp;gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Not in this part
&lt;/h3&gt;

&lt;p&gt;Registering and removing multi-factor authentication will be added to this part's appendix once the console has those screens. Switching to a team organisation after an invitation is covered, with its screens, in part 9.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 3: "Three environments plus one shared layer, out of the box: how a dotvault project is shaped." The three environments that came with the project, the Common layer, comparing environments, and archiving and deleting a project, one screen at a time.&lt;/p&gt;




&lt;p&gt;dotvault stores the secrets in your .env and config files server-side and hands them to any authorised developer, CI job or AI agent with a single command (&lt;a href="https://dotvault.io" rel="noopener noreferrer"&gt;https://dotvault.io&lt;/a&gt;). It will be released free of charge when this series ends, and the steps in these articles are the steps you will use on day one.&lt;/p&gt;

&lt;p&gt;If you find this useful, please like and follow.&lt;/p&gt;

&lt;p&gt;Our products are listed at &lt;a href="https://www.eln.ne.jp/products" rel="noopener noreferrer"&gt;https://www.eln.ne.jp/products&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;EarthLink Network&lt;/strong&gt; builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://dev.to/uehara/the-18-products-earthlink-network-builds-in-house-hb1"&gt;The 18 products EarthLink Network builds in-house&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For more about the company and each product, visit &lt;a href="https://www.eln.ne.jp" rel="noopener noreferrer"&gt;www.eln.ne.jp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotvault</category>
      <category>secrets</category>
      <category>devops</category>
      <category>cli</category>
    </item>
  </channel>
</rss>
