diff --git a/.agents/skills/nix-config-debug/SKILL.md b/.agents/skills/nix-config-debug/SKILL.md index afc804e0..d3d5e120 100644 --- a/.agents/skills/nix-config-debug/SKILL.md +++ b/.agents/skills/nix-config-debug/SKILL.md @@ -36,6 +36,7 @@ bump, and the commands that destroy rollback points are in the `nix-config-updat | remote host | anything on a Colmena host | `ssh root@ journalctl -b -p err`, same commands over SSH | | MicroVM guest | guest down or unreachable | on the VM host: `systemctl status microvm@` and `microvm-tap-interfaces@`; then `br0` | | secrets | missing or unreadable `/etc/agenix/*` | the `nix-config-secrets` skill | +| shell | nushell exits 1 with `File not found` | whether `~/.secrets/` exists on the host; the `nix-config-secrets` skill | ## 2. Read the evaluated value diff --git a/.agents/skills/nix-config-new-host/SKILL.md b/.agents/skills/nix-config-new-host/SKILL.md index f9651b88..ace675b0 100644 --- a/.agents/skills/nix-config-new-host/SKILL.md +++ b/.agents/skills/nix-config-new-host/SKILL.md @@ -17,7 +17,8 @@ in [hosts/README.md](../../../hosts/README.md). the configuration name; Niri desktops append `-niri`, because `just niri` deploys `$(hostname)-niri`. The `hostname` eval test encodes this. 2. **Secrets come from another repository.** The new host decrypts nothing until its host key is a - recipient in `nix-secrets`; see the `nix-config-secrets` skill. + recipient in `nix-secrets`; see the `nix-config-secrets` skill. A new desktop also needs its + synced dotfiles restored before the first switch, or the shell starts without them. 3. **The shared policy modules are not optional.** Several eval tests assert a policy for every configuration, so an under-wired host fails `just test` instead of failing in production. 4. **Build before you install.** `just test`, `just eval-host `, and `just build-host ` diff --git a/.agents/skills/nix-config-secrets/SKILL.md b/.agents/skills/nix-config-secrets/SKILL.md index a74e3ac8..52ced764 100644 --- a/.agents/skills/nix-config-secrets/SKILL.md +++ b/.agents/skills/nix-config-secrets/SKILL.md @@ -1,8 +1,9 @@ --- name: nix-config-secrets description: - Use when adding, changing, renaming, or removing an agenix secret, wiring one into a host, or - fixing a decryption or activation failure in this repo. + Use when adding, changing, renaming, or removing an agenix secret, deciding whether a secret + belongs to agenix or to the encrypted dotfiles sync, wiring a secret into a host, or fixing a + decryption or activation failure in this repo. --- # Working with secrets @@ -14,10 +15,35 @@ in this public repository. Read [secrets/README.md](../../../secrets/README.md) for the concepts and the recipient rule. +## Which flow a file belongs to + +Two age flows live in `~/codes/nix-secrets`, and a file belongs to exactly one. + +| Flow | Shape | Consumed as | `flake.lock` bump | +| ------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- | ----------------- | +| **agenix** (`secrets.nix`) | static and one-way: repository → host, decrypted at activation | `/run/agenix/` or an `environment.etc` copy | yes | +| **dotfiles sync** (`dotfiles/`) | changes on the host; `save`/`restore` are manual, outside Nix | a real file under `$HOME`, edited by hand or by its tool | **no** | + +agenix is for the secrets that barely change and that a Nix module or service reads from the +decrypted path. Everything else is a synced dotfile: Nix does not read it, it changes often, or it +is a config file its tool keeps rewriting that happens to carry a few secrets inside. The pin +decides it, not the encryption: `flake.lock` fixes the agenix revision, so every edit costs a +rebuild and switch on every desktop. + +Nothing in this flake evaluates the sync flow: no `secrets.nix` entry, no `agenix -r`, no lock bump, +so the three-step order in §2 does not apply to it. Its manifest and file list live in +`~/codes/nix-secrets/dotfiles/`; this repository carries the pointer, never the inventory. Do not +list synced paths here or copy one into a Nix module. + +`nushell-secrets.nu` already moved to `~/.secrets/nushell-secrets.nu`. It has no agenix entry and +must not get one back. + ## Core rules 1. **Never read a decrypted secret.** Reference its path; do not `cat`, copy, or print it. Prove a - change with metadata (mode, owner, timestamps), never with content. + change with metadata (mode, owner, timestamps), never with content. The same holds for a file the + sync flow owns: it is a live credential under `$HOME`, and its path is listed in the private + repository, not here. 2. **Three steps, in order.** The private repository changes first, then this repository's `flake.lock` moves `mysecrets` to that commit, then the declaration and consumer change here. `file = "${mysecrets}/x.age"` resolves against the locked revision, so a declaration that lands @@ -43,6 +69,7 @@ Read [secrets/README.md](../../../secrets/README.md) for the concepts and the re | Which host gets which group | `modules.secrets..enable` in `outputs//src/.nix` or a host module | | Consumers | modules reading `config.age.secrets."".path` (default `/run/agenix/`) | | Decryption key | `age.identityPaths`: the host's SSH host key; `/persistent/etc/ssh/...` on a preservation host | +| Secret-bearing dotfiles edited in place | `~/codes/nix-secrets/dotfiles/` — private, not a flake input, no entry in `secrets.nix` | The private repository groups the `.age` files: `desktop/` (only desktops decrypt them), `server/` (any server), `certs/`, and `public/`. @@ -124,9 +151,16 @@ you set, an old copy gone) instead of assuming activation did it. Check `secrets/`, `home/`, `modules/`, `hosts/`, outputs, tests, and the private repository. - Delete the `age.secrets` entry, its `environment.etc` placement, and every consumer in one change; - a declaration whose file no longer exists breaks activation. + a declaration whose file no longer exists breaks activation. On darwin also drop the path from the + `chown`/`chmod` list in `system.activationScripts.postActivation`. - Remove it from `secrets.nix` and delete the file in the private repository, then bump the lock. - A rename changes the attribute name and every consumer. The `.age` filename is separate. +- Moving a secret **out** of agenix into the sync flow is that deletion plus what agenix used to + guarantee: the consumer points at a `$HOME` path, and on a preservation host the directory joins + `preservation.preserveAt`, which also sets its mode. A sourced file that may be missing is left to + fail: nushell has no conditional `source` + ([nushell#8214](https://github.com/nushell/nushell/issues/8214)), and a host without the file is + misconfigured, not half-working. ## 7. Failure modes @@ -140,6 +174,12 @@ you set, an old copy gone) instead of assuming activation did it. `/persistent/etc/ssh/...` path, which exists before preservation mounts `/etc`. - **The first macOS activation fails** on the `/etc/agenix` copies, because they run before `activate-agenix` has decrypted anything. Run it again. +- **Nushell exits 1 with `File not found` and the shell has no aliases:** the sourced secret file is + missing. agenix guaranteed the path; the sync flow does not, and the failure is left loud on + purpose. Restore the file on that host. Never silence it with an empty placeholder: the sync tool + reads that as a local edit and reports a conflict. +- **A secret is gone after a reboot on a desktop:** it moved out of agenix and its `$HOME` directory + is not in `preservation.preserveAt`, so the tmpfs root drops it. ## Why these rules exist @@ -151,3 +191,5 @@ you set, an old copy gone) instead of assuming activation did it. host had; the reference moved to `modules/nixos/desktop/nix.nix` (core rule 4). - `260da1ee chore: rename the nushell secret, and forbid reading decrypted secrets` - the no-reading rule. +- The move of `nushell-secrets.nu` out of agenix - the `flake.lock` pin, not the encryption, made + each edit of a hand-edited secret cost a rebuild and switch on every desktop. diff --git a/agents/AGENTS.md b/agents/AGENTS.md index 845e66df..c3bc506b 100644 --- a/agents/AGENTS.md +++ b/agents/AGENTS.md @@ -131,7 +131,8 @@ For an impactful change, follow these steps in order, scaled to its risk: dependencies; ask before creating a flake or installing another way. - The user's interactive shell is **nushell**; bash is only started from within nushell, occasionally, for tasks nushell can't do. Put shell env, aliases, and per-session secrets in the - Nushell config (or `nushell-secrets.nu`), not in `bashrc`/`zshrc`. + Nushell config, not in `bashrc`/`zshrc`. The secret block is `~/.secrets/nushell-secrets.nu`: + hand-edited, sourced as code by the shell, not Nix-managed, and agents MUST NOT read or edit it. - Use `gh` for authorized GitHub operations; keep SSH for GitHub Git remotes and make sure the Nix-managed `~/.ssh/config` is used; rerun outside the sandbox if it is rejected. - Code layout: `~/codes` = personal, `~/work` = work code, `~/src/` = source checkouts. diff --git a/home/base/tui/shell/default.nix b/home/base/tui/shell/default.nix index 074cec93..fba7ef59 100644 --- a/home/base/tui/shell/default.nix +++ b/home/base/tui/shell/default.nix @@ -1,17 +1,17 @@ { + config, nu_scripts, ... }: { programs.nushell = { - # load the alias file for work # the file must exist, otherwise nushell will complain about it! # # currently, nushell does not support conditional sourcing of files # https://github.com/nushell/nushell/issues/8214 extraConfig = '' # decrypted secret: AI agents must not read it - source /etc/agenix/nushell-secrets.nu + source ${config.home.homeDirectory}/.secrets/nushell-secrets.nu # Directories in this constant are searched by the # `use` and `source` commands. diff --git a/hosts/idols-ai/preservation.nix b/hosts/idols-ai/preservation.nix index ea44276a..990fe2d6 100644 --- a/hosts/idols-ai/preservation.nix +++ b/hosts/idols-ai/preservation.nix @@ -260,6 +260,11 @@ in directory = ".gnupg"; mode = "0700"; } + # hand-edited secret config; Nix does not deploy it + { + directory = ".secrets"; + mode = "0700"; + } { directory = ".ssh"; mode = "0700"; diff --git a/opencode.jsonc b/opencode.jsonc index 2c286220..c971901f 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -3,8 +3,8 @@ // cost context here and nowhere else. // // mcp-grafana talks to the homelab Grafana. The service-account token stays in - // the environment (GRAFANA_SERVICE_ACCOUNT_TOKEN, set by nushell-secrets.nu), - // never in git. + // the environment (GRAFANA_SERVICE_ACCOUNT_TOKEN, exported by the hand-edited + // ~/.secrets/nushell-secrets.nu), never in git. "$schema": "https://opencode.ai/config.json", "mcp": { "servers": { diff --git a/secrets/README.md b/secrets/README.md index de745833..b48980f7 100644 --- a/secrets/README.md +++ b/secrets/README.md @@ -21,6 +21,18 @@ This directory contains this `README.md`, the `Justfile` with the agenix recipes `nixos.nix`/`darwin.nix` files that decrypt all my secrets via `agenix`. Then, I can use them in this flake. +## Two Flows, and Which One a File Belongs To + +agenix holds the secrets that barely change and that a Nix module or service reads from the +decrypted path. `flake.lock` pins `mysecrets`, so an agenix secret is a frozen revision: each edit +costs a lock bump plus a rebuild and switch on every host that reads it. + +Anything else is a synced dotfile: Nix does not read it, it changes often, or it is a config file +its tool keeps rewriting that happens to carry a few secrets inside. The private repository syncs +those as age ciphertext under `dotfiles/`, by hand and outside Nix, so there is no `secrets.nix` +entry and no lock bump. `nushell-secrets.nu` moved that way and is `~/.secrets/nushell-secrets.nu` +now; do not re-add it to `age.secrets`. The file list stays in the private repository. + ## Which Keys Go on a Secret **Every secret is decryptable by the desktops and by the offline `recovery_key`, and those two have @@ -229,6 +241,10 @@ the `age.secrets..path` argument, which defaults to `/run/agenix/`. 3. On the new host: 1. Clone this repo and run `nixos-rebuild switch` to deploy it, all the secrets will be decrypted automatically via the host private key. + 2. A desktop also needs the synced dotfiles: regenerate their recipient list from `desktop_keys` + and run the sync's `restore` there before the first switch, so the shell's secret block is + present when the config sources it. See + [Two Flows](#two-flows-and-which-one-a-file-belongs-to). ## Troubleshooting diff --git a/secrets/darwin.nix b/secrets/darwin.nix index f467ba39..cb5e44c0 100644 --- a/secrets/darwin.nix +++ b/secrets/darwin.nix @@ -77,12 +77,6 @@ } // user_readable; - # decrypted secret: AI agents must not read it, only reference the path - "nushell-secrets.nu" = { - file = "${mysecrets}/desktop/nushell-secrets.nu.age"; - } - // user_readable; - # work git identity, symlinked to ~/work/.gitconfig by home-manager "work-gitconfig" = { file = "${mysecrets}/desktop/work-gitconfig.age"; @@ -108,10 +102,6 @@ # The following secrets are used by home-manager modules # But nix-darwin doesn't support environment.etc..mode # So we need to change its mode manually - "agenix/nushell-secrets.nu" = { - source = config.age.secrets."nushell-secrets.nu".path; - }; - "agenix/work-gitconfig" = { source = config.age.secrets."work-gitconfig".path; }; @@ -124,11 +114,9 @@ if [ -d /etc/agenix ]; then ${pkgs.coreutils}/bin/chown ${myvars.username} \ /etc/agenix/ssh-key-romantic \ - /etc/agenix/nushell-secrets.nu \ /etc/agenix/work-gitconfig ${pkgs.coreutils}/bin/chmod 0500 \ /etc/agenix/ssh-key-romantic \ - /etc/agenix/nushell-secrets.nu \ /etc/agenix/work-gitconfig ${pkgs.coreutils}/bin/chown root /etc/agenix/rclone.conf diff --git a/secrets/nixos.nix b/secrets/nixos.nix index 4a39f757..38415332 100644 --- a/secrets/nixos.nix +++ b/secrets/nixos.nix @@ -115,12 +115,6 @@ in } // user_readable; - # decrypted secret: AI agents must not read it, only reference the path - "nushell-secrets.nu" = { - file = "${mysecrets}/desktop/nushell-secrets.nu.age"; - } - // user_readable; - # work git identity, symlinked to ~/work/.gitconfig by home-manager "work-gitconfig" = { file = "${mysecrets}/desktop/work-gitconfig.age"; @@ -145,16 +139,6 @@ in mode = "0000"; }; - # Used by a home-manager module, so it must be readable by the user. - # Setting `mode` makes environment.etc copy the file instead of - # symlinking the runtime secret, so the owner must be set as well — - # otherwise it becomes a world-readable, root-owned copy of the secret. - "agenix/nushell-secrets.nu" = { - source = config.age.secrets."nushell-secrets.nu".path; - mode = "0400"; - user = myvars.username; - }; - # Used by a home-manager module, so it must be readable by the user. # Setting `mode` makes environment.etc copy the file instead of # symlinking the runtime secret, so the owner must be set as well —