This document captures why the repo's SSH configuration uses a maximum-paranoia strict posture, what alternatives were considered, and the daily commands you'll actually use to live with it. It complements the short version in README.md, GitHub: SSH by default.
The threat being defended against is an in-process attacker
running as your user — for example, a malicious dependency, a
compromised CLI, or a postinstall script. Such an attacker doesn't
need to escalate privileges to do damage; if SSH keys are sitting
unlocked in an ssh-agent (or in the system Keychain with
UseKeychain yes), the attacker can silently authenticate to
GitHub as you and push, fork, or rewrite history without ever
asking you to type a passphrase.
The posture chosen here removes that capability by refusing to cache keys at all. Every SSH operation prompts for the passphrase. Friction is the price; the deliberate inability of any process to silently auth as you is the product.
The Host * block in the stowed ~/.ssh/config sets:
Host *
AddKeysToAgent no
UseKeychain no
IdentitiesOnly yesEach setting is doing specific work:
AddKeysToAgent no— first use of a key does not implicitly load it into the runningssh-agent. Without explicitssh-add, the key stays on disk encrypted and must be unlocked per operation.UseKeychain no— macOS does not pull the passphrase from the login Keychain. A key isn't silently unlocked just because you logged into the Mac.IdentitiesOnly yes— only theIdentityFilenamed in the matching host block is offered. Prevents the agent from spraying every loaded key at every host (which both leaks key fingerprints and can lock you out for too many bad attempts).
The cost: each git fetch, git push, or ssh git-<alias> asks
for a passphrase. The benefit: a compromised process cannot reuse
a cached unlock, because there is no cache.
| Posture | How it looks | Friction | Protection vs in-process attacker | What would prompt switching |
|---|---|---|---|---|
| Strict (current) | Host * with AddKeysToAgent no, UseKeychain no, IdentitiesOnly yes. Passphrase per operation. |
High — every op | High — no silent auth path exists | (this is the chosen posture) |
| Pragmatic-strict | Strict on Host *; selectively UseKeychain yes and AddKeysToAgent yes on specific host blocks (e.g. one frequently-used Host git-<alias>). |
Medium — friction reduced for chosen aliases only | Medium — attacker can act as you against the chosen aliases, but not the others | Friction on a single alias becomes the dominant cost and you accept silent auth on that one alias |
| Pragmatic-default | Host * with UseKeychain yes and AddKeysToAgent yes. Apple Keychain holds the passphrase; first use unlocks for the session. |
Low — passphrase on first use only | Low — once unlocked, any process running as you can auth | You decide convenience outweighs the in-process supply-chain risk |
| Hardware-backed | Key material lives on a YubiKey or in the Secure Enclave; SSH talks to a hardware-backed agent. Touch / biometric required per use. | Low-to-medium — a tap, not a passphrase | High — key material never reaches user-space memory in a usable form | You acquire the hardware and migrate keys onto it |
The choice between these is not "which is correct" but "which matches the threat you actually have". This repo's posture treats in-process attackers as a real, ongoing concern.
Re-evaluate the posture if any of these change:
- You adopt hardware-backed keys (YubiKey / Secure Enclave). Hardware-backed gives you the same protection as Strict with much lower friction; the trade evaporates.
- The threat model shifts. If the realistic attacker stops being "code running as me" and becomes something else (lost laptop, network adversary, etc.), strictness is solving the wrong problem and other controls matter more.
- Friction starts producing observable bad behaviour. If you notice yourself shortening passphrases, reusing them across keys, or otherwise weakening the keys themselves to make the friction tolerable, the friction has become a negative security signal — it's now causing the harm it was meant to prevent.
Absent one of these, drift away from strict is drift away from a deliberate choice.
The strict posture means you will run ssh-add explicitly. These
four commands cover almost every interaction:
ssh-add -t 12h ~/.ssh/<your-github-key> # load for the day
ssh-add -l # list loaded keys
ssh-add -d ~/.ssh/<your-github-key> # remove one key
ssh-add -D # remove all keysssh-add -t 12h <key>— at the start of a working session, unlock the key once and keep it in the agent for 12 hours. After that, the agent forgets it and you'll be prompted again. Use this when you know you'll do many GitHub operations in a session.ssh-add -l— list whatever's currently in the agent. Use this to check whether a passphrase prompt is going to happen before a long-running script tries to push.ssh-add -d <key>— remove a single key from the agent. Use this when stepping away from the machine in a context where you don't want a logged-in session to retain auth.ssh-add -D— remove all keys. The hard reset; use this before handing the machine to anyone else, or when you're unsure what's loaded.
For longer ad-hoc sessions, prefer -t with an explicit timeout
over loading without one — a forgotten unlocked key is exactly
what the strict posture was meant to prevent.
The strict posture is only meaningful if it actually applies. If
something later quietly flips UseKeychain yes or
AddKeysToAgent yes (e.g. an installer running ssh-add once
that triggers a GUI prompt to save to Keychain), the protection is
gone but the config file might still look strict. Check the
effective config, not the file:
ssh -G git-<alias> | grep -iE "usekeychain|addkeystoagent"
# expected: both 'no'ssh -G resolves all Host matches and Include files and prints
what SSH will actually use. If either line says yes, find what
changed before continuing.
A second drift check, for whether a key has been silently cached:
ssh-add -l
# expected: "The agent has no identities." at the start of a session
# (until you explicitly run ssh-add)If the agent has identities you didn't load, something added them on your behalf. Investigate before pushing.
Standing rule: an install is not finished until its phone-home is closed. Not "eventually", not "when someone remembers" — checking and disabling a tool's reporting is part of installing it, the same way pinning a version is.
Two things make this harder than it sounds, both learned by getting them wrong:
A global environment variable is not coverage. home/.zshrc exports ~28
opt-out variables including DO_NOT_TRACK=1. Homebrew ignores every one of them
— it reads HOMEBREW_NO_ANALYTICS, which was missing, so the tool that installs
every other tool was reporting for months while everything downstream looked
covered. Confirm per tool which knob it actually reads; do not assume a blanket
variable reached it.
.zshrc runs for INTERACTIVE shells only. A cron job, a CI step, a
#!/bin/sh script or a non-interactive agent call gets none of those exports. So
where a tool has a config file, the setting belongs there — the file applies
wherever the tool runs, and the environment variable is the belt, not the braces.
Two corollaries worth stating:
- A missing key reads as ENABLED. Upstream defaults are on, and a default can flip on upgrade. State the opt-out explicitly rather than relying on absence.
- An upstream version check is phone-home. herdr's
version_checkand lazygit'supdate.method: promptboth reach the vendor on a timer, and both collide with the release-cooldown posture — nothing here auto-adopts a same-day release.
| Tool | State | Enforced by |
|---|---|---|
| Homebrew | off | brew analytics off + HOMEBREW_NO_ANALYTICS in .zshrc |
| bun | off | telemetry = false in home/.bunfig.toml |
| lazygit | off | update.method: never (upstream default polls every 14 days) |
| herdr | off | version_check + manifest_check false |
| Zed | off | telemetry.diagnostics / .metrics false |
| VSCode / Cursor | off | telemetry.telemetryLevel: "off" |
| Docker Desktop | off | AnalyticsEnabled: false |
| Docker CLI | off | no OTLP endpoint configured — the CLI exports only when one exists; there is no opt-out key to write |
| Claude Code | partial, deliberate | _claude_launch clears DO_NOT_TRACK for that binary ONLY, so Remote Control works; OTEL metrics and error reporting stay off |
The Claude Code line is a decision, not a gap. Any other exception needs the same explicit sign-off.
/refresh has a telemetry lane that re-derives every row above live and
prints >>> against anything reporting. It tests each file's readability
before its value, so a file it cannot open is reported as SKIPPED rather than as
a finding — an alert that cries wolf gets ignored, which costs more than the
alert was worth.
The strict posture above governs git transport (clone / fetch / push), which is SSH-only. The GitHub REST API (issues, pull requests, CI/Actions status) is a separate channel that needs a token, not an SSH key. To serve that without weakening the SSH posture:
- A fine-grained, read-only Personal Access Token is held in the macOS
Keychain and exposed as
$GH_TOKENonly inside Claude Code sessions (set by the_claude_launchwrapper) - never in ordinary shells, never as a global export. It grants read on issues / PRs / Actions / metadata and noContents(cannot read source); any write call returns HTTP 403. gh auth login/setup-git/refreshremain blocked (a shell wrapper plus a PreToolUse hook), so the read-only token never touches~/.gitconfigand adds no HTTPS credential helper.
Net: a leaked API token can read metadata - it cannot push, rewrite history, or read source - a deliberately smaller blast radius than the SSH key, and confined to Claude sessions.
The posture above governs the SSH transport (clone / fetch / push). Claude Code —
the agent that edits these files — has its own, separate security model: an OS-level
Bash sandbox, six permission modes, and an un-bypassable protected-paths guard over your
dotfiles (a PreToolUse hook or a permissions.allow rule cannot silence it — only
bypassPermissions can), plus the 2026 CVE history. That surface, and the
sandbox.credentials hardening recommendation, lives in
CLAUDE_CODE_SECURITY.md.
This is not advocacy for everyone to adopt strict. It is a record of why this repo uses it, so that someone reading the SSH config six months from now (including future-me) doesn't mistake the strictness for an oversight and "fix" it back to defaults. The postures table exists precisely so that a reasoned switch is possible; the triggers section names the conditions under which that switch would make sense.