Skip to content

Latest commit

 

History

History
318 lines (261 loc) · 15.7 KB

secrets.md

File metadata and controls

318 lines (261 loc) · 15.7 KB

Secrets

Checked on 2026-10-13.

This repository is public, so every secret in it is age ciphertext in a file named encrypted_*. chezmoi decrypts those files on apply with the private key at ~/.config/chezmoi/key.txt, which is never committed. Bitwarden holds a copy of the key in the item chezmoi age identity, so a new machine can fetch it. The public half, the recipient, is in .chezmoidata/secrets.toml.

What is encrypted

Secret Source path
~/.ssh/config private_dot_ssh/encrypted_private_config.age
Machine pages of the machines skill dot_agents/skills/private_machines/references/encrypted_private_*.md.age

.chezmoidata/machines.toml stays in plaintext on purpose. Bundles and roles are not secret. Its pixi cache paths do name university filesystems, and they could move behind encryption at a cost. A machine's first apply runs before its key arrives, so without the paths it would put the pixi cache in home, which on sicc is an NFS quota.

Checks before publication

Check Scope
Gitleaks pre-commit hook Changed files from the Git index
Gitleaks pre-push hook Every outgoing commit, including merge changes and commit or annotated-tag messages
GitHub secret scanning and push protection GitHub-supported secret patterns

GitHub secret scanning and push protection are enabled. Enabling secret scanning also starts GitHub's automatic history scan. There is no separate local history audit or Gitleaks CI scan. CI still runs the existing encryption and key guards.

The core bundle installs Gitleaks v8.30.1 through dotfiles-fetch. The pin in .chezmoidata/downloads.toml must match VERSION in .github/scripts/check-secrets.py. .gitleaks.toml adds the broad age-identity pattern from the existing guard. The hook checks account paths separately. CI does not assert the version (checked on 2026-10-18).

The local scanner reads raw Git blobs. It does not run the age diff driver or decrypt private files. It skips ciphertext only under an encrypted_*.age name with an age header. Plaintext in protected directories fails the check. Findings show the file, line, and rule, but not the matched value.

The pre-push hook queries the destination's refs and checks commits that the destination does not already contain. It checks new branches and every ref in a multi-ref push. Missing tools, scan errors, shallow history, and refs that do not resolve to commits stop the operation.

.chezmoiscripts/run_onchange_after_08-source-repo.sh.tmpl installs both local hooks during apply. An unrelated pre-push hook stays unchanged, with a warning. For a clone outside the chezmoi source path, install them explicitly:

pre-commit install
ln -s "$PWD/.github/scripts/check-secrets.py" "$(git rev-parse --git-path hooks/pre-push)"

Do not replace an existing pre-push hook without checking its purpose. Local hooks can be bypassed. GitHub protection covers supported patterns only. Gitleaks does not reliably recognize private addresses, domains, or account names without a targeted rule. Keep private machine facts encrypted.

Run local regression tests without scanning this repository's history:

python3 .github/scripts/check-secrets-tests.py

Which machines have the key

The workstation, the laptop, imrl and sicc. The vps never gets it, because it is internet-facing, and it applies everything except the encrypted files.

Check a machine rather than trusting this list. A claim that imrl and sicc had no key was once copied from note to note while it was false.

age-keygen -y ~/.config/chezmoi/key.txt    # must print age1ke4rf2j…
chezmoi managed | grep '^\.ssh/config$'    # the encrypted files are managed

Reading and editing

chezmoi cat ~/.ssh/config      # print the plaintext
chezmoi edit ~/.ssh/config     # decrypt, open $EDITOR, re-encrypt

An agent cannot drive chezmoi edit, which waits for an interactive editor. It edits the decrypted file in ~ and hands it back with chezmoi re-add <path>, which keeps the file encrypted.

On a machine with the key, git diff --no-ext-diff in this repository shows .age files decrypted. Difftastic bypasses Git's text-conversion driver. GitHub always shows ciphertext. Checked on 2026-10-04.

Adding an encrypted file

  1. chezmoi add --encrypt <path>, then git status to confirm the new encrypted_* file landed in this repository.
  2. Add the target to the no-key block at the end of .chezmoiignore.
  3. If the file sits in a directory that held no secrets before, add that directory to PROTECTED in .github/scripts/check-encrypted.sh.

Setting up a new machine

  1. chezmoi init --apply --promptString machine=<name> xiaosq2000. This installs rbw with the secrets bundle and skips the encrypted files, because the key is not there yet.
  2. Set up rbw and run rbw unlock; see below for the first time.
  3. chezmoi apply. This fetches the key from Bitwarden. If it says the vault is locked, the fetcher will not try again by itself; install the key by hand as shown below.
  4. chezmoi apply once more. An apply that starts without the key leaves the encrypted files out even if it fetches the key partway, so they land now.
  5. Check with the two commands under Which machines have the key.

The fetcher cannot unlock the vault itself, because chezmoi runs it without a terminal. Instead of steps 3 and 4, you can install the key by hand and apply once:

mkdir -p ~/.config/chezmoi
rbw get "chezmoi age identity" > ~/.config/chezmoi/key.txt
chmod 600 ~/.config/chezmoi/key.txt
chezmoi apply

First-time rbw setup

rbw config set email <your-address>
rbw register    # once, with the personal API key; bitwarden.com only
rbw login
rbw unlock
  • Against bitwarden.com, skipping rbw register makes rbw login fail with api request returned error: 400. That is bot detection, not a wrong password. The API key is in the web vault under Settings, Security, Keys, "View API Key". A self-hosted Vaultwarden instead needs rbw config set base_url <url> and no API key.

  • On a GNOME desktop, rbw's password prompt grabs the keyboard and pointer, so you cannot switch to the browser to copy the API key. Use the terminal prompt instead, and paste with Ctrl+Shift+V in kitty:

    rbw config set pinentry pinentry-curses
    rbw stop-agent    # required; the running agent keeps the old prompt
  • Over SSH the prompt is a terminal prompt already.

Rotating the key

  1. Create a new key with age-keygen -o.
  2. Re-encrypt every encrypted_* file to the new recipient.
  3. Update ageRecipient in .chezmoidata/secrets.toml and the Bitwarden item.
  4. Run chezmoi init on every machine.

Keep the old key until every machine has the new one. A machine with neither cannot apply at all.

Keeping agents away from private keys

Claude Code, Codex and opencode run as you, so by default they can read any file you can. Two kinds of file are kept from them: ssh private keys, and the age identity, which decrypts every encrypted_* file in this repository. If an agent read either one, the key would go to the model provider, and an agent misled by a web page or a file it read could send the key anywhere.

The guarded files:

  • every name in ~/.ssh except config, config.d, known_hosts, authorized_keys and *.pub, which covers a key with a custom name without listing it;
  • ~/.config/chezmoi/key.txt.
Agent What refuses the read Where it comes from
Claude Code Read deny rules for the default key names and the age identity, and the key-guard hook dot_claude/modify_settings.json merges both into ~/.claude/settings.json
Codex The key-guard permissions profile, whose bubblewrap sandbox cannot open any ~/.ssh/id_* file except a .pub, and the key-guard hook dot_codex/modify_private_config.toml and dot_codex/hooks.json
opencode read rules in opencode.json, and a plugin that runs the key-guard hook private_dot_config/opencode/

The hook is ~/.agents/hooks/key-guard.py. It refuses a tool call that names a guarded file, and tells the agent to ask you instead. The exceptions are commands that name an ssh key only to use it or to inspect its metadata: ssh, scp or sftp -i, ssh -o IdentityFile=, ssh-add, ssh-keygen -y or -l, and ls, stat, test, chmod or mkdir, as well as inline text that never opens a file: git commit -m messages, gh PR and issue --title and --body text, and grep or rg search patterns. Flags that open a file to read message or pattern data (git commit -F, gh --body-file, grep -f) remain strictly refused. The hook splits the command into words to find those places. A command sent over ssh is checked the same way, so ssh sicc 'mkdir -p ~/.ssh' passes and ssh sicc 'cat ~/.ssh/id_ed25519' does not. A command the split cannot follow, such as one with $(...), is refused if it names a key at all, and no command may name the age identity. The calls it must refuse and the ones it must let through are listed in .github/scripts/check-key-guard.sh, which both pre-commit and CI run.

In Claude Code, its own Read rules still refuse ls or stat on a default key name, because Claude Code counts those commands as reading the file. ls -la ~/.ssh shows the same and passes. Checked with Claude Code 2.1.276 on 2026-09-22.

The settings files of Claude Code and Codex are rewritten by the agents themselves, so chezmoi does not own them. The modify scripts add the guard and leave every other key alone. The Claude Code script only ever adds rules, so deleting one from it does not delete it from ~. The Codex script rewrites its whole profile on every apply.

Codex skips the hook until you trust it. On each machine, after the first apply that brings ~/.codex/hooks.json, open Codex and trust the hook in /hooks. Do it again whenever hooks.json changes. Until then only the sandbox profile guards the keys in Codex.

A key with a custom name is already covered by the hook and by opencode's rules. Codex's sandbox covers any name that starts with id_ and does not end in .pub, so name a new key id_*. Claude Code's Read rules list the default names only (id_rsa, id_ed25519 and the rest). To cover another name there, add it to the list in dot_claude/modify_settings.json.

Agents log in with the key through the ssh agent, so ssh works in Codex's sandbox although it cannot open the key file. For ssh to run there at all, the key-guard profile also hides /etc/ssh/ssh_config.d. In the sandbox, root's files show as owned by nobody, and ssh refuses to read a config file it includes that has such an owner. On Ubuntu that stopped every ssh, git push and scp in Codex with Bad owner or permissions. Hidden, the directory matches nothing, and ssh runs without its drop-ins. Checked with codex-cli 0.155.1 and OpenSSH 10.2 on 2026-09-22. Where no ssh agent holds the key, ssh in Codex's sandbox cannot log in with it.

Codex's base :workspace profile marks .git read-only, which stops in-sandbox git commit, staging (index.lock), and branch operations without prompting for approval. The key-guard profile carves out write access for .git under :workspace_roots, while keeping .git/hooks and .git/config read-only. That lets git operate smoothly in the sandbox while preventing any script or process from tampering with git hooks or repository configuration (such as core.fsmonitor or core.hooksPath).

Routine GitHub CLI operations are managed by Codex execpolicy rules in ~/.codex/rules/gh.rules (deployed from dot_codex/rules/gh.rules). It allows read-only queries and routine PR work (gh pr view, list, diff, checks, status, create, edit, comment, review, ready), prompts before destructive or shared PR state changes (gh pr merge, gh pr close), and forbids credential-touching or destructive commands (gh auth, gh repo delete, gh secret, gh ssh-key). Commands not covered by the rules fall back to Codex's normal approval policy. Checked with codex-cli 0.160.0 on 2026-10-03.

What none of this stops:

  • A command that builds the path at run time. The hook matches text, so a command that decodes a key path from base64 gets past it. For an id_* key, Codex's sandbox still refuses the read. In Claude Code and opencode nothing else does. Claude Code's own sandbox would, but it was turned down on 2026-09-21: it also puts every Bash command behind a network allowlist, and it needs bubblewrap and socat installed on each machine.
  • A key path passed on through a file. The hook lets ls -d ~/.ssh/* print key paths and refuses piping them into xargs cat, but a command that saves them to a file for a later command to open gets past it. That is the same gap as a path built at run time.
  • A Codex command you approve to run outside the sandbox. Only the hook sees it.
  • A command you type yourself with ! in Claude Code. No hook sees it.
  • What an MCP server does in its own process. The hooks see the arguments of an MCP call, not the files the server opens.

A passphrase on the key is the one guard that no agent gets around: ssh-keygen -p -f ~/.ssh/id_ed25519. An agent that reads the file then gets ciphertext. A hardware key made with ssh-keygen -t ed25519-sk goes further: the file on disk is only a handle to the device.

Caveats

  • A missing key aborts the whole apply, not just the file. Every target sorting after the encrypted one is left untouched. That is why every encrypted target needs its entry in the no-key block of .chezmoiignore. CI checks this with a keyless apply. As a one-off escape hatch, chezmoi apply --exclude=encrypted works.

  • The attribute order is encrypted_private_. private_encrypted_ is not an error: chezmoi leaves the file unmanaged and writes nothing, with no message.

  • encryption = "age" has to sit above [data] in .chezmoi.toml.tmpl. Below it, TOML makes it data.encryption, and chezmoi carries on unencrypted with only a warning. CI does not check this order separately (checked on 2026-10-18).

  • The key fetcher, .chezmoiscripts/run_onchange_before_00-age-identity.sh.tmpl, must never exit non-zero. It runs before any file is written, so a failure there would stop every file from applying. It must also stay run_onchange_ with its rbw-present marker line. As run_once_ it never retries after the first apply fails for lack of rbw, and as a plain run_ it makes chezmoi verify fail for good.

  • Local guards work only where the hooks are installed. Gitleaks checks staged files and outgoing commits. The existing check-encrypted, detect-private-key, no-age-identity, and no-home-paths commit hooks remain active. GitHub push protection rejects supported secret patterns even without local hooks. CI runs after publication and cannot prevent a leak. .gitignore names the usual key files too, but it stops only git add .. git add -f gets past it.

  • chezmoi add writes into whichever source tree its config names. With an unexpected HOME or XDG_CONFIG_HOME, a new encrypted file can land in another repository. Run git status after every add --encrypt.

  • Codex's deny list takes globs under ~/.ssh, never an exact path. For an exact path that does not exist, Codex creates an empty 0444 file there while each sandboxed command runs, and a command that dies leaves the file behind. ssh then warns about it as an unprotected private key. An exact path in /etc, such as /etc/ssh/ssh_config.d, is safe, because Codex runs as you and cannot create anything there. Checked with codex-cli 0.155.1 on 2026-09-22. To delete such files, which touches empty files only:

    find ~/.ssh -maxdepth 1 -name 'id_*' -type f -empty -delete