This guide covers security best practices and emergency procedures for managing encrypted dotfiles with age and chezmoi.
- Security Overview
- Emergency Key Rotation
- CI/CD Security Checks
- Audit Trail
- Package Manager Supply Chain Defense
- Developer-Tool Update Workflow
- Best Practices
This repository uses a two-layer encryption model to protect sensitive data:
key.txt.age (in repository, password-protected)
↓ Decrypt with password from 1Password
~/key.txt (local age identity/private key)
↓ Decrypt other encrypted files
encrypted_*.age (SSH config, Google IME dictionary, etc.)
Key Points:
key.txt.ageis stored in the repository, encrypted with a password (scrypt)- The password is stored securely in 1Password
- Only those with the password can extract the age private key
- The age private key (
~/key.txt) is used to decrypt other encrypted files - NEVER commit
~/key.txtto the repository
key.txt.age- Password-protected age private keyencrypted_*.age- Age-encrypted sensitive files (SSH configs, etc.)- All encryption uses the age tool with strong cryptographic primitives
IMPORTANT: This procedure is for emergencies only (key leak, device compromise, etc.). Do NOT perform routine key rotation unless necessary.
Rotate your age key immediately if:
- Your
~/key.txtfile is accidentally committed to a public repository - Your device is lost, stolen, or compromised
- You suspect unauthorized access to your encrypted files
- You accidentally shared your age private key
# Generate new age key pair
age-keygen --output ~/key.txt.new
# Backup the old key temporarily (in case rotation fails)
cp ~/key.txt ~/key.txt.backupExtract the new public key:
grep "^# public key: " ~/key.txt.newNavigate to your chezmoi source directory:
cd ~/.local/share/chezmoiRe-encrypt key.txt.age:
# Create a secure temporary directory
TMPDIR="$(mktemp -d)"
chmod 700 "$TMPDIR"
# Encrypt the NEW private key with a password
# Note: key.txt.age is password-protected, not recipient-encrypted
age -p -o key.txt.age.new ~/key.txt.new
# Enter a strong password (store in 1Password immediately!)
# Verify the new encrypted file works (with error handling)
# This will prompt for the password you just set
age -d -o "$TMPDIR/test_decrypt.txt" key.txt.age.new
diff ~/key.txt.new "$TMPDIR/test_decrypt.txt" || {
echo "ERROR: Re-encrypted file verification failed!"
rm -rf "$TMPDIR"
exit 1
}
# Replace old with new
mv key.txt.age.new key.txt.age
# Clean up the temporary directory
rm -rf "$TMPDIR"Re-encrypt other .age files:
# Fail loudly on any step — never overwrite an encrypted file with an
# untrusted result. `set -o pipefail` makes pipeline failures fatal too.
set -euo pipefail
# Create a secure temporary directory
TMPDIR="$(mktemp -d)"
chmod 700 "$TMPDIR"
trap 'rm -rf "$TMPDIR"' EXIT
# Find all .age files (excluding key.txt.age)
git ls-files '*.age' | grep -v '^key\.txt\.age$'
# Extract and validate the new public key
NEW_PUBLIC_KEY=$(grep "^# public key: " ~/key.txt.new | sed 's/^# public key: //')
if [ -z "$NEW_PUBLIC_KEY" ]; then
echo "ERROR: Could not extract public key from ~/key.txt.new" >&2
exit 1
fi
# For each tracked .age file (excluding key.txt.age): decrypt with old
# key, re-encrypt with new key, verify decryption with the new key, then
# atomically replace the original. `set -euo pipefail` plus the verify
# step ensure any per-iteration failure aborts before the corresponding
# mv runs, so an .age file is never overwritten with an unreadable
# replacement. Files rotated in earlier iterations stay rotated.
while IFS= read -r F; do
age -d -i ~/key.txt.backup -o "$TMPDIR/temp_decrypted.txt" "$F"
age -r "$NEW_PUBLIC_KEY" -o "$F.new" "$TMPDIR/temp_decrypted.txt"
age -d -i ~/key.txt.new -o /dev/null "$F.new"
# Atomic replace only after the .new file has been proven decryptable.
mv "$F.new" "$F"
done < <(git ls-files '*.age' | grep -v '^key\.txt\.age$')# Replace old key with new key
mv ~/key.txt.new ~/key.txt
chmod 600 ~/key.txt
# Test that chezmoi can decrypt files
chezmoi diffcd ~/.local/share/chezmoi
# Verify no plaintext keys are being committed
git status
git diff
# Commit the re-encrypted files
git add key.txt.age
git add private_dot_config/google_ime/encrypted_google_ime_dictionary.txt.age
# Add any other re-encrypted .age files
git commit -m "security: rotate age encryption key
Re-encrypted all .age files with new age key due to [reason].
- Generated new age key pair
- Re-encrypted key.txt.age with new password
- Re-encrypted all sensitive files
"
git push# Securely delete old key backup
rm -f ~/key.txt.backup
# Update password in 1Password
# Store the new password for key.txt.age in 1Password- All
.agefiles re-encrypted with new key -
chezmoi diffworks without errors - Changes committed and pushed to GitHub
- New password stored in 1Password
- Old key backup deleted
- Test recovery on different machine (optional but recommended)
This repository includes automated security checks that run on every push and pull request.
The GitHub Actions workflow (.github/workflows/security-checks.yml) performs:
-
Plaintext Key Detection
- Prevents accidental commit of
~/key.txt(unencrypted private key) - Checks for common key file naming patterns
- PASS: No plaintext key files found
- FAIL: Plaintext key detected in commit
- Prevents accidental commit of
-
Age Encryption Verification
- Verifies
key.txt.ageexists in repository - Validates all
.agefiles are properly encrypted - Checks file format headers (
age-encryption.org/v1or-----BEGIN AGE ENCRYPTED FILE-----) - PASS: All files properly encrypted
- FAIL: Missing or corrupted .age files
- Verifies
-
Secret Detection (gitleaks)
- Scans the full git history with gitleaks (version-pinned binary, checksum-verified)
- On pull requests, fetches the protected base branch's
.gitleaks.tomlwhen available so the PR cannot weaken its own scanner config; fetch failures are fatal rather than silently falling back to defaults - Detects 150+ provider patterns, including every GitHub token variant (
ghp_,gho_,github_pat_, …) - Config:
.gitleaks.tomlat the repo root (no repo-wide allowlists) - PASS: No secrets detected
- FAIL: Potential secrets found
-
AI Tool Update Policy Invariants
- Verifies Claude Code updater policy source settings in
private_dot_claude/settings.json - Requires
env.DISABLE_AUTOUPDATER="1",autoUpdatesChannel="stable",enabledPlugins["codex@openai-codex"]=true, andextraKnownMarketplaces.openai-codexto point exactly atgithub:openai/codex-plugin-ccwithautoUpdate=false - Requires
env.FORCE_AUTOUPDATE_PLUGINSto stay unset in Claude settings - PASS: AI-tool update policy source settings match ADR 0026
- FAIL: Claude/Codex source settings drift from ADR 0026
- Verifies Claude Code updater policy source settings in
The workflow uses:
- gitleaks - Secret scanning across full git history (version-pinned + checksum-verified)
- zizmor - GitHub Actions workflow hardening lint (in
ci.yml) - Python - Static checks for JSON policy invariants
- Shell scripts - Lightweight checks without external dependencies
- No password required - CI cannot decrypt files (password not stored)
- Cannot verify decryption (no password in CI)
- gitleaks detects structured secrets, not unstructured PII (names, card numbers)
- A local pre-commit hook is bypassable with
--no-verify; required CI is the fail-closed merge gate, and GitHub push protection adds server-side prevention subject to repository bypass policy - Manual review still important for sensitive changes
- Review the error message - Identifies which check failed
- Remove sensitive data if detected
- Fix corrupted .age files if encryption check failed
- Verify you didn't commit
~/key.txt(plaintext key)
If you believe a gitleaks result is a false positive, prefer removing or rewriting the fixture. If an allowlist is genuinely required, add the narrowest scoped entry to .gitleaks.toml. For pull requests, the scan uses the protected base branch's config, so an allowlist added in the same PR will not affect that PR until the trusted base config is updated by a separate reviewed change or maintainer-approved process.
The same baseline applies beyond CI (see ADR 0028):
- Local (L2) — a gitleaks
pre-commithook is installed globally viainit.templateDir(~/.git-template/hooks/pre-commit); new clones inherit it only when nopre-commithook already exists. Existing repos with old git-secrets/custom hooks need manual inspect/replace/chain migration because git templates never overwrite hooks. Bypassable with--no-verify. - CI (L3) — gitleaks + zizmor, authoritative once configured and required by branch protection. Other repos get the same gate with one line:
uses: toku345/dotfiles/.github/workflows/secret-scan.reusable.yml@<commit-sha-or-version-tag>(@mainis a convenience tradeoff, not the hardened default). - Server (L3) — enable GitHub secret scanning + push protection per repo (free on public repos). Repository push protection blocks detected secrets before they land, but users with write/bypass privileges can bypass or request bypass depending on repository policy; use delegated bypass or equivalent controls when bypass review must be enforced.
- Fleet sweep (L4) —
repo-security-auditreports posture across all repos;repo-security-audit --history-sweepruns gitleaks over each repo's full history.
Git history serves as the audit trail for encrypted files:
- Change history:
git log --follow -- '*.age'tracks all changes - Diff check:
git diffshows which files changed (content is encrypted) - Commit messages: Document reasons for important changes
# All .age file changes
git log --oneline --name-only -- '*.age'
# Specific file history
git log --follow -- key.txt.age
# Recent changes with dates
git log --pretty=format:"%h %ad %s" --date=short -- '*.age'- Meaningful commit messages - Explain why encrypted files changed
- Separate commits - Don't mix encrypted file changes with other changes
- Review before push - Always check
git diffbefore pushing
Hardening defaults are committed for npm/bun, pip, and uv to mitigate the class of attack exemplified by Mini Shai-Hulud (2026-04, npm postinstall + malicious bun runtime download) and the lightning@2.6.2/2.6.3 PyPI compromise (2026-04).
For the broader developer-environment update policy covering VS Code extensions, Homebrew/Linuxbrew, apt, asdf, Codex, Claude, and high-privilege CLIs, see ADR 0026. The ADR records the policy; concrete enforcement is partial and is tracked via the follow-up issues linked there.
| File | Setting | Effect |
|---|---|---|
~/.npmrc |
ignore-scripts=true |
Disables pre/postinstall lifecycle scripts for npm only. Blocks the most common arbitrary-code-execution vector. |
~/.npmrc |
min-release-age=7 |
Time-based isolation for npm only. Refuses npm versions published within the last 7 days (unit is days; npm ≥ 11.10.0). Reduces exposure to freshly published malicious versions that bypass lifecycle-script gating by running at require/import time. Mirrors bun's minimumReleaseAge and uv's exclude-newer. |
~/.bunfig.toml |
[install] minimumReleaseAge = 604800 |
Time-based isolation for bun's npm package manager. Refuses npm packages younger than 7 days (in seconds). Mirrors uv's exclude-newer. |
~/.bunfig.toml |
[install] ignoreScripts = true |
Disables lifecycle scripts for bun only. bun does not honor ~/.npmrc's ignore-scripts, so this is a separate defense, not a backup. As a global toggle it skips scripts even for packages listed in a project's trustedDependencies. |
~/.config/pip/pip.conf |
[install] only-binary = :all: |
Refuses sdists; installs pre-built wheels only. Prevents setup.py / build-backend code from executing at install time. |
~/.config/uv/uv.toml |
exclude-newer = "7 days" |
Time-based isolation: refuses to resolve PyPI distributions uploaded within the last 7 days. Most malicious versions are detected and yanked inside this window. |
~/.config/uv/uv.toml |
no-build = true |
Refuses sdists; installs pre-built wheels only. Mirrors pip's only-binary = :all:. Prevents PEP 517 build-backend / setup.py code from executing at install time — exclude-newer alone does not close this path. |
All three time-based settings (min-release-age, minimumReleaseAge, exclude-newer) are expressed as durations, not absolute dates, so the cooldown window slides automatically — no periodic maintenance is required. If a value blocks a legitimately-needed fresh package, dependency resolution selects an eligible older version when constraints allow; otherwise it fails closed.
chezmoi apply also fails loudly when npm exists but is older than 11.10.0, because older npm versions do not enforce min-release-age. The apply-time gate also checks the default effective npm config from a temporary empty directory, so project-local .npmrc files do not affect the check. Per-command or per-project recovery overrides are still intentional escape hatches; they should only be used in the isolated recovery workflow below.
A few subtleties that are easy to read past in the table above:
- Time-based isolation does not stop build-time code execution. uv's
exclude-neweronly filters which distributions are resolvable; once a sdist is selected, itssetup.py/ PEP 517 build backend still executes arbitrary Python at install time.no-build = trueis what closes that path. pip'sonly-binary = :all:plays the same role. Treatexclude-newerandno-buildas complementary, not redundant. ignore-scripts=truesilently skips lifecycle scripts. Many npm packages legitimately rely onpostinstallto fetch platform binaries or run native builds. Under this default,npm install/bun installsucceed but the runtime later fails with a missing module or binary. When such a failure is suspected, follow the isolated recovery flow in the next section rather than relaxing the defense in the daily project tree.- Lifecycle-script gating does not stop require-time code execution.
ignore-scripts/ignoreScriptsonly blockpre/postinstallhooks. A malicious version whose payload lives in the package main entry runs when application coderequires/imports it — the script gate never sees it. Themin-release-age/minimumReleaseAgecooldown reduces exposure to fresh poisoned versions that are likely to be yanked inside the window. Treat the script gate and the cooldown as complementary, not redundant. ~/.npmrcand~/.bunfig.tomlare independent. Disabling scripts in one file does not cover the other tool — see the table above.
The per-tool flags interact with the user-global defenses in different —and easy-to-misread — ways:
npm install --ignore-scripts=false <pkg>re-enables lifecycle scripts for the entire invocation, including every transitive dependency —not just<pkg>. A single recovery command therefore widens the trust surface across all packages being resolved at the same time.- bun's
--ignore-scriptsflag is a boolean toggle (bun install/add --ignore-scripts); it has no=falseform. Even disabling the setting via a project-localbunfig.toml [install] ignoreScripts = falseonly governs the project's own scripts unless dependency scripts are also trusted (defaults plustrustedDependencies). - Under user-global
~/.bunfig.tomlignoreScripts = true,bun add --trustandtrustedDependenciesalone do not restore a dependency's lifecycle scripts — verified empirically against bun 1.3.3. Two paths actually unblock them under that global default:bun pm trust <pkg>(post-install retry; scoped to the named deps) and a project-localbunfig.tomlsettingignoreScripts = falsecombined withtrustedDependencies.
The recommended workflow is to do recovery in a throwaway project, verify the scripts work and the lockfile/trust list are clean, then carry only the audited metadata (and the per-project override, if needed) back to the main workspace:
# 1. Spin up an isolated workspace outside the daily project tree.
mkdir -p "/tmp/recovery-$(date +%s)" && cd "$_"
echo '{"name":"recovery","private":true}' > package.json
# 2. Install so the lockfile reflects the real dependency graph.
# bun: dep scripts are blocked by default — review what bun blocked
# and, if any are required, retry with `bun pm trust` (overrides
# user-global ignoreScripts for those exact packages).
bun install <pkg>
bun pm untrusted
bun pm trust <pkg>
# npm: re-running scripts is invocation-wide — only do this in the
# throwaway, never in the main workspace.
npm install --ignore-scripts=false <pkg>
# 3. Audit the lockfile diff (trusted deps, transitive surface, sources)
# before copying the dependency entry back into the real project.
# For bun, also commit the resulting `trustedDependencies` entry. If
# the package's scripts must also run in the main repo's daily
# install (rare; most native deps publish prebuilt binaries), add a
# project-local `bunfig.toml` with `[install] ignoreScripts = false`
# so the override is repo-scoped and does not weaken any other
# project. The user-global defenses stay intact throughout.Temporary overrides for the non-script gates differ by tool:
# npm: widen the cooldown for an urgent patch newer than the window.
# Invocation-wide (affects every transitive dep resolved in the command), so
# run it only in the throwaway recovery workspace and audit the lockfile diff.
# npm has no per-package exclude, unlike bun's minimumReleaseAgeExcludes.
npm install --min-release-age=0 <pkg>
# bun: widen the cooldown
# per-invocation:
bun add --minimum-release-age 0 <pkg>
# per-project (edit project-local bunfig.toml):
# [install]
# minimumReleaseAge = 0
# per-package (persistent, in ~/.bunfig.toml):
# [install]
# minimumReleaseAgeExcludes = ["@types/node", "typescript"]
# pip: allow sdist for a specific package that ships no wheel.
# Must disable the global only-binary in the same invocation, otherwise
# the two flags are additive and pip exits with "No matching distribution".
# Use the CLI form — it takes the highest precedence over pip.conf and
# avoids the ambiguity of env→config merging for cumulative options:
pip install --only-binary=:none: --no-binary=<pkg> <pkg>
# uv: temporarily widen the time window (per-invocation, no config edit)
UV_EXCLUDE_NEWER="0 seconds" uv pip install <pkg>
# or persistent per-package override in ~/.config/uv/uv.toml:
# exclude-newer-package = { foo = "0 seconds" }
# Note: `uv add --exclude-newer=...` writes the value into pyproject.toml
# (project-scoped persistent), so it is not actually a one-shot override.
# uv: allow sdist for a specific package that ships no wheel.
UV_NO_BUILD=0 uv pip install <pkg>
# or persistent per-package override in ~/.config/uv/uv.toml:
# no-build-package = ["foo"]Use overrides only for the single command (or single project) that needs them — never edit the user-global config files to weaken defaults.
npm config get ignore-scripts # → true
npm --version # → 11.10.0 or newer
npm config get before # → timestamp about 7 days ago
grep -E 'minimumReleaseAge|ignoreScripts' ~/.bunfig.toml
pip config list # → install.only-binary = :all:
grep -E 'exclude-newer|no-build' ~/.config/uv/uv.toml # → both settings presentEditor extensions, OS package managers, high-privilege CLIs, and AI coding tools are update channels that can each become an arbitrary-code-execution path. ADR 0026 sets the policy: move routine updates into reviewable windows, but keep security updates timely. This section is the operational runbook for the AI-tool and high-privilege-CLI surface, with the implemented Homebrew/asdf controls and documented VS Code manual setup below.
Committed in ~/.claude/settings.json:
| Key | Value | Effect |
|---|---|---|
env.DISABLE_AUTOUPDATER |
"1" |
Disables automatic updates for both the Claude Code binary and all plugins, including the built-in claude-plugins-official marketplace. This is the global kill switch. |
autoUpdatesChannel |
"stable" |
When an update does run through claude update, or if the kill switch is ever unset on a machine, follow the stable channel — a build that is typically ~1 week old and skips versions with major regressions. This approximates the 7-day routine-update delay, but it is a release-channel policy rather than a hard package-age gate. |
extraKnownMarketplaces.openai-codex.autoUpdate |
false |
Per-marketplace auto-update off for the Codex plugin marketplace. Redundant under DISABLE_AUTOUPDATER, but kept explicit to document intent for that credential-adjacent tool. |
This user-level settings file registers the trusted Codex marketplace and disables its auto-update path, but it is not a marketplace allowlist. Claude Code's hard marketplace-source gate is the managed-settings-only strictKnownMarketplaces; deploy that separately if non-allowlisted marketplace additions must be blocked before network or filesystem access.
Plugin auto-updates are covered by the kill switch. The official marketplace defaults to auto-update on, but DISABLE_AUTOUPDATER overrides that for plugins as well as the binary — so the plugins that drive internal automation (pr-review-toolkit behind the /pr-review gate, commit-commands, hookify, etc.) stay frozen until a reviewed update. Do not set FORCE_AUTOUPDATE_PLUGINS=1 — that flag re-enables plugin auto-updates even while the binary updater is disabled, which is the opposite of this policy.
Intentional plugin updates are manual and reviewed: before updating, record the marketplace name, installed plugin versions or SHAs (claude plugin list --json), and the source diff or release notes to inspect. Then run claude plugin marketplace update <marketplace> followed by a target-specific update such as claude plugin update pr-review-toolkit@claude-plugins-official, record the after versions or SHAs with claude plugin list --json, and smoke-test the affected automation (for example, a small /pr-review run on a trivial branch when pr-review-toolkit changes, since its specialist agents are reused via agentType). The quarterly review window and component-specific bump checklists for these plugins, agmsg, and cc-session-finder live in docs/claude-code-plugins.md.
Install Claude Code with the official native installer. For routine installs or reinstalls that should follow the delayed channel, download the installer first, record the digest, inspect the file, and only then execute it so download failures and unexpected script changes are visible:
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL https://claude.ai/install.sh -o "$tmp"
openssl dgst -sha256 "$tmp"
${PAGER:-less} "$tmp"
bash "$tmp" stableFor routine native-installer updates, use claude update after autoUpdatesChannel=stable is present in ~/.claude/settings.json. Avoid npm-based install/update paths for this machine; explicit latest-channel installs belong only in the cooldown-bypass cases below.
Repeatable verification: run python3 tests/codex/verify_claude_update_policy.py from the chezmoi source directory. After applying source changes, chezmoi diff -- ~/.claude/settings.json should show no unintended drift for the managed user settings.
These tools run with privileges that touch credentials, source control, cloud accounts, containers, or input devices, so an auto-pulled compromised release is high-impact. Review release notes before upgrading; pinning is acceptable, but each pin needs at least a quarterly manual review so security fixes are not missed:
codex,claude— AI tools and their plugin systemsgh— GitHub auth/token;op— 1Password CLI, a direct path to the vault that Mini Shai-Hulud targetsdocker/ container tooling- cloud CLIs (
aws,gcloud), credential/session helpers karabiner-elements(input monitoring), editor casks (VS Code, etc.)
Manual update flow: brew update → inspect brew outdated and the target's release notes → unpin only the reviewed target → brew upgrade <name> → smoke-test → re-pin if appropriate. For security-sensitive libraries (git, curl, openssl, ca-certificates), temporarily re-enable Homebrew's dependent repair path while upgrading: env -u HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK brew upgrade <name>, then run brew linkage --test or review and repair affected dependents explicitly. This is a documented manual control; a pinned/reviewed inventory or reminder mechanism is still needed before high-privilege CLI/cask review can be considered fully enforced.
Committed so routine OS-package and runtime updates are deliberate, not implicit. Linux wiring lives in dot_bashrc, macOS in config.fish (the repo deploys shell config per-OS), and the run-once bootstrap installer exports the same Homebrew variables before its brew calls; the asdf config is OS-agnostic.
| Location | Setting | Effect |
|---|---|---|
~/.bashrc (Linux) / ~/.config/fish/config.fish (macOS) |
HOMEBREW_NO_AUTO_UPDATE=1 |
No implicit brew update on install — installing a formula no longer silently pulls fresh metadata for everything. |
| same | HOMEBREW_NO_INSTALL_UPGRADE=1 |
brew install <x> no longer upgrades already-installed formulae as a side effect. |
| same | HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 |
After an install/upgrade, do not run the extra outdated-dependent check/auto-upgrade pass. NO_INSTALL_UPGRADE alone does not cover this (verified against Homebrew 5.1.14). The requested formula/cask dependency plan can still include dependencies, and those dependencies are part of the reviewed change. Trade-off: outdated dependents and broken linkage are not auto-repaired; use brew linkage --test, then brew upgrade <dependent> for reviewed outdated dependents or brew reinstall <dependent> for broken linkage. |
| same | HOMEBREW_CASK_OPTS=--require-sha |
Refuse casks without a checksum (macOS-relevant; inert on Linux). A legitimately unsigned cask (e.g. some fonts) installs with a one-off override: env HOMEBREW_CASK_OPTS= brew install --cask <name>. |
~/.config/asdf/.asdfrc |
plugin_repository_last_check_duration = never |
Never auto-sync the asdf plugin short-name repository (default: every 60 min). |
~/.config/asdf/.asdfrc |
disable_plugin_short_name_repository = yes |
Disable the short-name plugin repository entirely; add plugins by explicit Git URL only. |
Note (ask mode): plan visibility for install/upgrade/reinstall (print the plan; prompt only when it includes dependencies, dependants, or packages beyond the named arguments) is Homebrew's default behaviour in current rolling builds on this machine (Homebrew 5.1.15-247-g067da6f). The exact 5.1.15 tag only announced that future default and enabled it automatically for $HOMEBREW_DEVELOPER, so older Homebrew/Linuxbrew builds may still need explicit --ask until upgraded. HOMEBREW_ASK=1 — which this policy previously set — is now marked upstream as a deprecated compatibility variable and must not be exported by the managed environment. The opt-out is HOMEBREW_NO_ASK, which is intentionally not set. As before, the prompt is skipped without a TTY, so this is plan visibility, not an enforcement gate.
Operational rules (asdf): pin exact versions in .tool-versions (never latest); do not run asdf install <tool> latest or asdf plugin update --all; update a runtime only when intentionally reviewing that upgrade.
Scope limitation (Homebrew): these controls apply to brew invocations that inherit the managed environment: new Linux bash sessions, macOS fish sessions, and the repository's run-once bootstrap installer. Existing shells from before deployment, GUI-launched commands, sudo/env -i, shells that do not source the managed config, and future non-rc automation must set the HOMEBREW_* variables explicitly before invoking brew.
Scope limitation (asdf): these controls are shell-scoped. ~/.config/asdf/.asdfrc is read only because dot_bashrc/config.fish export ASDF_CONFIG_FILE to point at it; an asdf invocation that does not inherit that environment (a non-interactive script, cron job, or GUI-launched process) falls back to asdf's default ~/.asdfrc (absent) and its built-in defaults, where the short-name repository is enabled. This is acceptable today because the hardened action — asdf plugin add <short-name> — is run interactively and no repo automation calls asdf. If a non-rc asdf path is ever added, manage ~/.asdfrc (e.g. a symlink to the XDG file) to close it.
Homebrew updates follow the manual flow above (brew update → brew outdated → upgrade the named, reviewed target). For security-sensitive libraries, unset HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK for the upgrade so Homebrew can repair outdated or broken dependents, then smoke-test the affected tools. Security fixes bypass the cooldown — see When to bypass the cooldown.
VS Code's extension marketplace is an update channel: an auto-updating extension can pull compromised code that runs with editor — and, via tasks/debuggers, shell — privileges. ADR 0026 requires extension auto-update off, update checks on, manual application updates, and workspace trust restricted. This is documented as manual machine setup rather than chezmoi-managed: the settings file is app-owned (VS Code rewrites it), full of personal settings, and lives at platform-specific macOS-only paths, so a managed file would churn and risk clobbering personal config for marginal benefit.
Set these in VS Code user settings (Cmd+, -> Open Settings (JSON)), for whichever build you run. macOS default-profile paths:
- Stable:
~/Library/Application Support/Code/User/settings.json - Insiders:
~/Library/Application Support/Code - Insiders/User/settings.json
If you use VS Code Profiles, use the Settings editor's Apply Setting to all Profiles action when possible, or apply and verify these settings in each active profile's settings file as well as the default user settings. Profile settings live under User/profiles/<profile ID>/settings.json; on macOS:
- Stable profile:
~/Library/Application Support/Code/User/profiles/<profile ID>/settings.json - Insiders profile:
~/Library/Application Support/Code - Insiders/User/profiles/<profile ID>/settings.json
A profile settings.json exists only after that profile overrides settings. Always verify the effective value in the active profile through VS Code's Settings UI; if the active profile has no profile settings file, also verify the default user settings and keep using VS Code's Apply Setting to all Profiles action for these four controls.
Verify every VS Code build/profile that exists on the machine: Stable default, Insiders default if installed, and every active profile settings file. Swap in Code - Insiders or a User/profiles/<profile ID>/settings.json path as needed:
S="$HOME/Library/Application Support/Code/User/settings.json"
jq '{autoUpdate: ."extensions.autoUpdate", autoCheck: ."extensions.autoCheckUpdates", update: ."update.mode", trust: ."security.workspace.trust.untrustedFiles"}' "$S"
# expect: autoUpdate=false, autoCheck=true, update="manual", trust="newWindow"The jq check works when settings.json is strict JSON. If the file uses JSONC comments or trailing commas, verify in VS Code's Settings JSON view or use a JSONC-capable parser.
Checklist before treating a machine/build/profile as compliant:
- Identify the active VS Code build and profile for the window you use.
- Apply or verify the four settings in the default user settings and in every active profile settings file.
- If Settings Sync is enabled, confirm the synced profile keeps the same values after sync completes or after any sync conflict/restore.
- Re-run this checklist whenever the machine, VS Code build, active profile, or Settings Sync state changes.
As of 2026-06-03, all four controls were verified by hand on the current Macs. Treat that as dated evidence, not a permanent invariant: rerun the checklist for every new or rebuilt Mac, new VS Code build, new or switched profile, Insiders install, Settings Sync enablement, or Settings Sync conflict/restore. If Settings Sync is enabled, set these in the synced profile so they propagate instead of being overwritten.
Editor-migration note: moving to a single-binary editor without an extension marketplace (e.g. Helix, or Lem) would remove this attack surface only if the editor is installed through the reviewed Homebrew flow and no separate editor plugin/package updater is enabled. Treat such a migration as a net supply-chain reduction under those conditions; re-evaluate this subsection if VS Code is retired.
The 7-day cooldown is the default for routine updates, not a brake on security. Apply an update immediately (skip the cooldown) when:
- a security advisory or CVE fix names the version, or there is an active exploit, or
- it is a break/fix needed to restore work, or
- it is an OS security update.
Security-sensitive libraries — git, curl, openssl, ca-certificates — must not be long-term pinned; update them promptly. The cooldown reduces exposure to malicious fresh releases; it must never delay a fix for a known-exploited bug.
-
Never commit plaintext keys
- Keep
~/key.txtoutside git-tracked directories - Only commit
key.txt.age(password-protected) - CI will catch accidental commits
- Keep
-
Store passwords securely
- Use 1Password for the
key.txt.agepassword - Enable 2FA on 1Password
- Keep Emergency Kit in secure physical location
- Use 1Password for the
-
Minimize exposure
- Only decrypt when needed
- Use secure temporary directories (
mktemp -dwithchmod 700) - Clean up decrypted files immediately
-
Regular backups
- 1Password Emergency Kit (printed, in safe)
- GitHub repository (encrypted files)
- See backup-restore.md for details
# Always review what you're committing
git status
git diff
# Check for secrets locally (requires gitleaks; same engine as CI)
gitleaks dir . --no-banner --redact # working tree
gitleaks git . --no-banner --redact # full history
# Verify encrypted files. Aggregates failures and exits non-zero so this
# block is safe to embed in CI / pre-commit, not just eyeball checks.
(
set -uo pipefail
failed=0
while IFS= read -r f; do
if ! head_line=$(head -n 1 "$f"); then
echo "ERROR: cannot read $f" >&2
failed=1
continue
fi
if printf '%s\n' "$head_line" | grep -qE 'age-encryption.org|BEGIN AGE ENCRYPTED'; then
echo "OK: $f"
else
echo "ERROR: $f does not look like an age file" >&2
failed=1
fi
done < <(git ls-files '*.age')
exit "$failed"
)-
Rotation Policy
- No routine rotation required for personal dotfiles
- Rotate only in emergencies (see Emergency Key Rotation)
- Document reason for rotation in commit message
-
Access Control
- Keep
key.txt.agepassword to yourself - Don't share age private key (
~/key.txt) - Review repository access regularly
- Keep
-
Audit Trail
- Git history tracks all changes to encrypted files
- Commit messages should explain sensitive changes
- Monitor notifications for unexpected changes
-
New machine checklist
- Clone repository via SSH (not HTTPS)
- Decrypt
key.txt.ageto~/key.txt - Set permissions:
chmod 600 ~/key.txt - Verify:
chezmoi diffworks - See backup-restore.md for full setup
-
Machine retirement
- Securely delete
~/key.txt - Clear shell history if it contains passwords
- Consider key rotation if machine was compromised
- Securely delete
- Age encryption tool
- Chezmoi documentation
- Backup and Restore Guide
- GitHub Account Security Audit - Account hardening + credential inventory runbook
- AWS Credential Hardening - Remove static keys, migrate to short-lived (aws-vault / IAM Identity Center)
- CLAUDE.md - Repository overview
{ "extensions.autoUpdate": false, // extensions do not auto-update "extensions.autoCheckUpdates": true, // but available updates are still surfaced "update.mode": "manual", // the app updates only on request "security.workspace.trust.untrustedFiles": "newWindow" // untrusted files open in a restricted window }