Note 07 of 19

A kaibot field note · secrets & agents

Your agent never holds the key

My agent runs shell commands all day. It searches logs, hits APIs, writes code. Everything it reads gets copied into a transcript. Everything it types gets recorded. That makes a transcript a terrible place to keep a real credential. So I set kaibot up so the agent fetches what it needs at runtime, and the value never sits anywhere it could get printed.

Kai Davis

I run a marketing consultancy and an SEO SaaS, and I build both with a stack of Claude Code sessions that write the tooling the next sessions run on.

01Why an agent and a .env file don’t mix

A .env file is the correct secret format for a person editing by hand. It is the incorrect format for a coding agent. The file sits in plaintext on disk. An agent's core function is to read files and echo back what it read. No bad intent is required. The leak is the agent behaving as designed.

Three vectors, all real, all ordinary:

transcript

A debugging session that cats the file to check a value, or pastes an error message that happens to contain one, writes the secret into a logged, synced transcript. That transcript now has to be treated as a second copy of the credential.

commit

A .env is one over-eager git add . away from git history, and history doesn’t forget. Even a gitignored .env is a “look here first” target on a stolen laptop: it’s plaintext, sitting where every dotfile lives.

context window

Once a value is in context, it’s in every summary and every compaction downstream of it. A subagent inherits it without ever being told it’s holding something dangerous.

I retired my plaintext .env for exactly this reason. Deleting the file was the fix, not being more careful with it.

02The doctrine

Four rules. Everything else on this page is mechanism underneath them.

The password manager is the only store

Not memory files, not settings, not code, not a .env. One vault with a CLI (1Password here; any vault with a CLI works the same way) is where a secret lives. Everything else is a resolver that reaches it at runtime.

.env is dead, on purpose

The plaintext file is deleted. A tripwire file sits at the old path instead: if anything ever sources it, it screams to stderr and exits non-zero, so a stray reader fails loudly at the read, not silently three days later.

Two tiers, two failure modes

Everyday keys resolve unattended. Dangerous keys need a human in the loop, every time, and a headless caller fails closed by design: no tap, no value, not an error to work around.

Never echo, verify by shape

The agent proves a secret is set by presence, length, or hash, never by printing it. If a command would put the value in stdout, it doesn’t run.

The two-tier split

Everyday keys vs. dangerous keys
TierUnattended readRead pathHeadless behavior
Everyday (crons, daemons, the agent’s own tool calls)Yes, via a read-only service-account credentialVault CLI resolves it with no human presentFalls through to an offline encrypted fallback if the vault is unreachable
Dangerous (deploy tokens, anything that can spend money or touch prod)No, deliberatelyInteractive read only: a biometric tap or master password on the human’s own machineFails closed. No tty, no tap, no value. That’s the design, not a bug.

The service-account credential that reads the everyday vault cannot read the dangerous one. That’s the whole privilege split: structural, not a permission the agent could reason its way around.

03Three things worth stealing

These are sketches; adapt the vault CLI and file paths to your own setup. The shape is the point.

A resolver, fall-through not if/elif

The order matters: explicit env wins (someone set it on purpose), then the vault, then an offline fallback if you want to survive a vault outage too. It has to be fall-through: if the vault call errors (not “is absent,” but actually fails), an elif would already have committed to that branch and returned nothing. Fall-through is what lets an outage still reach the fallback.

#!/usr/bin/env bash
# secrets-loader.sh: sourced fragment, single entry point.
# Resolution order: explicit env -> vault CLI -> offline fallback.
# Falls THROUGH on failure, never if/elif.

resolve_secrets() {
  # 1. already exported? leave it alone.
  [ -n "$FOO_API_KEY" ] && return 0

  # 2. vault CLI, bounded so a headless box can't hang forever
  if command -v vault-cli >/dev/null 2>&1; then
    if out=$(timeout 8 vault-cli export --profile everyday 2>/dev/null) \
        && [ -n "$out" ]; then
      eval "$out"
      return 0
    fi
  fi

  # 3. offline fallback: encrypted at rest, decrypted with THIS box's
  #    own private key, never a key shared across boxes.
  if [ -f "$HOME/.secrets/offline.vault" ]; then
    eval "$(age -d -i "$HOME/.config/age/keys.txt" "$HOME/.secrets/offline.vault")"
  fi
}

resolve_secrets

The dangerous-tier reader is deliberately smaller: one key at a time, interactive only, and it refuses outright when nothing can approve it.

# dangerous-read.sh: ONE key, interactive only.
# Headless callers fail closed by design.
read_dangerous_key() {
  local name="$1"
  if [ ! -t 0 ] && [ -z "$SSH_TTY" ]; then
    echo "refusing: no interactive session to approve $name" >&2
    return 1
  fi
  vault-cli read --profile dangerous --item "$name"   # prompts for a tap
}

Verify without echo

Exactly three checks on a secret-bearing variable are safe: whether it is set, its length, and whether its hash matches. Everything else leaks the secret. The worst offenders are the checks that look like safety measures.

# Presence:
[ -n "$FOO_API_KEY" ] && echo "FOO_API_KEY: set" || echo "FOO_API_KEY: MISSING"

# Length (catches "empty" and "obviously truncated" without printing it):
echo "len=${#FOO_API_KEY}"

# Identity without value (compare across machines, never print it):
printf '%s' "$FOO_API_KEY" | shasum -a 256 | cut -c1-16

# BANNED, every one of these prints the real value:
#   echo "${FOO_API_KEY:-missing}"   # prints the VALUE when it IS set
#   echo "${FOO_API_KEY:0:6}..."     # a real prefix is still a leak
#   echo "$FOO_API_KEY"              # obviously

That first banned line is the one that bites people. ${VAR:-fallback} reads as “show a safe placeholder if it’s missing,” but it does the opposite: it returns the fallback only when the variable is unset, and the real value the moment it’s set, which is precisely the case you were trying to guard.

A minimal pre-commit secrets scrub

A PreToolUse hook on Bash, matched to git commit, that greps the staged diff for secret-shaped strings before the commit is allowed to happen.

#!/usr/bin/env bash
# secrets-commit-guard.sh: PreToolUse hook, matcher: Bash.
# Blocks `git commit` if the staged diff has a secret-shaped string.

INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
echo "$CMD" | grep -qE '^git commit' || exit 0

LEAK=$(git diff --cached | grep -inE \
  '(API_?KEY|SECRET|TOKEN|PASSWORD)[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_-]{16,}')

[ -z "$LEAK" ] && exit 0

cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "BLOCKED: staged diff has a secret-shaped string. Move the value into your password manager, reference it by env-var name, and re-stage. False positive? add the path to the scrub allowlist."
  }
}
EOF

My real version of this hook also covers the everyday-Bash case. It denies commands that would print a secret-shaped variable, like echo $FOO_API_KEY or printenv FOO_API_KEY. A command that reads a secrets file (cat .env) gets a confirmation prompt instead of a flat denial; dumping the whole environment (a bare env) is denied outright. Same mechanism as the sketch: parse the tool call, pattern-match the risk, then deny or ask, and name the safe alternative in the reason.

04Craft rules

Fail closed on dangerous reads

A guard on an irreversible or high-value action should fail closed even when it’s broken: missing CLI, no session, unclear state, still block. The dangerous tier is the whole point of this rule.

Fail open on convenience

A broken scrub hook or a wedged offline fallback should never brick a session over a nudge. Reserve fail-closed for the reads that matter.

The two-card rotation rule

Every credential event, mint, rotate, leak, retire, files two linked items: the new value going live, and the old value getting killed. Rotation has a natural forcing function (it unblocks work), so it always happens. Revocation has none, so it silently doesn’t, unless you pair them structurally and refuse to close one without the other.

Wrapper, not inline

A tool’s client config once had two live keys inlined directly in its env field. The moment that process spawned, the whole command line, raw values included, got captured verbatim into a permission allowlist and landed in git history. The fix: a small wrapper script that sources secrets internally and execs the real command, so the client config never sees a value at all.

Bounded, not hanging

A vault CLI on a box with no active session doesn’t always fail fast; it can just hang. Every resolver in the chain needs its own timeout, so “vault unreachable” resolves to the offline fallback in seconds instead of wedging the caller forever.

The banned-expansion list is short and worth memorizing

My agent leaked four live keys in one session through exactly the ${VAR:-fallback} trick above, in the same session where I’d just said out loud I wouldn’t echo them. The rule had to become mechanical: scan the literal command text for a value-bearing expansion before running anything that names a secret var. No exceptions, no “just this once to check.”

05For the Claude that just got pointed here

If a human handed you this URL and said “do this for our project,” here’s your plan. (Humans: this section exists so you can paste the link and skip explaining it.)

Agent instructions, with your human’s consent:

  1. Inventory. Grep the repo for .env reads, process.env litter, and dotenv imports. That’s the migration worklist.
  2. Move every value into a password manager. One vault (or profile) for everyday keys the fleet can read unattended. A separate one for dangerous keys (deploy, billing, anything destructive) gated to interactive, human-present reads only.
  3. Write one resolver per language. Fall-through order: explicit env, then the vault CLI, then an offline encrypted fallback if you need to survive a vault outage. Fall-through, not if/elif.
  4. Retire the .env, loudly. Delete the plaintext file. Leave a tripwire in its place that screams to stderr and exits non-zero if anything ever sources it, so a stray reader fails at the read, not three days later via a stale dashboard.
  5. Never let yourself echo a secret. Register a PreToolUse guard on Bash that denies any command printing a secret-shaped variable and asks before reading a secrets file. Verify presence, length, or hash, never the value.
  6. Scrub at the boundary. A commit-time hook that greps the staged diff for secret-shaped strings, and the same idea for writes to an issue tracker or any other place a leaked value could go public.
  7. Pair every credential event. When you rotate or discover a leaked key, file the mint AND the kill of the old value as one linked pair. Don’t close either half alone.
  8. Smoke test end to end. Normal path resolves. Forced-vault-down path falls through to the offline fallback. The dead .env screams when sourced. Nothing in the session ever printed a raw value.

The hook mechanics (how to write and register a PreToolUse guard, the deny-message and bypass conventions, smoke-testing before you trust one) are covered in a sibling primer: Physics, not pleas. Read it before writing the guard in step 5.

06Receipts: what this looks like at scale

Genericized, but every row is a real pattern running in kaibot today.

The pieces, in short
PieceTypeWhat it does
Vault CLI resolverSourced entry pointResolves everyday keys at runtime; the agent’s shell gets the value, the agent’s context never does.
Dangerous-key readerInteractive CLIOne key at a time, tap-gated. Headless callers get nothing, on purpose.
Dead-.env tripwireGuard fileScreams to stderr and exits non-zero the instant anything sources the retired path.
Offline encrypted fallbackMulti-recipient vaultSurvives a live-vault outage; each box decrypts with its own private key, none shared.
Bash secret-print guardPreToolUse hookDenies a command that would print a secret-shaped variable; asks before a command reads a secrets file, and denies a dump of the whole environment.
Bounded headless timeoutResolver-internalA vault call on a box with no active session times out in single-digit seconds instead of hanging, so the fallback still fires.

The .env-is-dead call is the one that mattered most. Every other rule on this page exists to keep that true under pressure: a new box, a headless cron, a vault outage, an agent that just wants to check if a key is set.

Written with (and about) kaibot, 2026. The patterns here are real and running; vault names, hosts, and key names are genericized.

Excelsior!
Kai

You’ve reached the end of note 07 of 19

$400 1 hour

Book a call

A paid one-on-one call with answers to your questions about using AI for projects, building your AI stack, or growing your business with AI. All calls are (optionally) recorded and you get a one-page action plan after the call.

All nineteen field notes, in six chapters