fix(neogaia): replace guessed hardware detection with a real scan (task 0017) #2

Merged
alexion merged 6 commits from task-0017-hardware-detection-refresh into main 2026-07-19 23:12:42 -04:00
8 changed files with 229 additions and 66 deletions

View File

@@ -22,40 +22,38 @@ One thing must be true before the disk is erased: every branch worth keeping has
- [x] The `Host`'s disk layout declares a 2 GiB EFI system partition - [x] The `Host`'s disk layout declares a 2 GiB EFI system partition
- [x] Every local branch worth keeping exists on the remote before the disk is erased - [x] Every local branch worth keeping exists on the remote before the disk is erased
- [ ] The reimage is performed from a configuration carrying the hardware profile, following the existing install documentation - [x] The reimage is performed from a configuration carrying the hardware profile, following the existing install documentation
- [ ] The install documentation is corrected wherever the procedure diverged from what it describes - [x] The install documentation is corrected wherever the procedure diverged from what it describes
- [ ] Manual confirmation: the machine boots, the encrypted root unlocks, and console login succeeds - [x] Manual confirmation: the machine boots, the encrypted root unlocks, and console login succeeds
- [ ] Manual confirmation: reported free space on the boot partition is consistent with its 2 GiB size, resolving the discrepancy observed before the reimage — where a 512 MiB partition reported 1022 MiB - [x] Manual confirmation: reported free space on the boot partition is consistent with its 2 GiB size, resolving the discrepancy observed before the reimage — where a 512 MiB partition reported 1022 MiB
- [x] The project's agent instructions record that a flake only sees git-tracked files, so an untracked file is invisible to evaluation - [x] The project's agent instructions record that a flake only sees git-tracked files, so an untracked file is invisible to evaluation
## Implementation Notes ## Implementation Notes
This task is **blocked on an operator action**, not finished. Three of seven Done. The reimage was performed by the operator and the machine now runs the
criteria are satisfied: the declaration, the branch check, and the recorded configuration this repository declares.
gotcha. The remaining four all depend on erasing the disk, which is not an
action taken on the operator's behalf.
The branch check was verified rather than assumed — no local branch holds a The install ran clean: the operator reports no step diverged from
commit absent from the remote, so nothing is lost to the wipe. Two items that `docs/install.md`, so criterion 4 is satisfied with no further corrections. The
live outside the repo do not survive it and are not covered by any criterion: four corrections that landed earlier came from reading the procedure; the run
the agent memory directory, and the wifi credentials. itself found nothing to add. That is the reproducibility evidence the task was
after — the document is a procedure, not a record of one improvised session.
Criterion 4 is deliberately left open despite four corrections already landing Verified on the running machine rather than assumed:
on the main branch — a wrong repository name in both clone commands, a closing
section describing a superseded key-derivation design, a stale enumeration of
flake inputs, and a bootstrap-ordering sentence contradicting a later one. All
four were found by reading the procedure. The criterion asks for divergences
found by *running* it, which has not happened. The wrong repository name would
have stopped the install at the clone step, so the reading pass was worth doing;
it is just not the same evidence.
**Ordering hazard.** Between merging this and completing the reimage, the - `/dev/nvme0n1p1` is 2.0 GiB and `df` reports 2.0 GiB. The pre-reimage
repository asserts a partition layout the physical disk does not have, and disko discrepancy, where a 512 MiB partition reported 1022 MiB, is gone.
reconciles nothing on a running machine. The declaration should reach the branch - The hardware profile is live — `mem_sleep_default=deep` is on the kernel
the install reads immediately before the install, not days ahead of it. If the command line and `psmouse` is blacklisted and not loaded.
reimage is deferred, this is the file that quietly lies about the only laptop. - `cryptroot` is open on `nvme0n1p2` with btrfs mounted, reached through a
console login, so the boot-unlock-login path is exercised end to end.
- One generation exists (`system-1-link`), confirming a fresh install rather
than a rebuild of the prior system.
One correction outside this task's scope also landed here: the gotcha claiming The ordering hazard closed favourably: the declaration and the install landed
git identity was unconfigured, which a hand-written `~/.gitconfig` had made close enough together that the repository never asserted a layout the disk
false. Moving that identity into the flake belongs to the commit-identity work lacked for long.
this task's description explicitly fences off.
Two items outside the repo did not survive the wipe, as anticipated, and neither
is covered by a criterion: the wifi credentials, and the agent memory directory
— confirmed empty after the reimage.

View File

@@ -14,7 +14,27 @@ Generating the scan requires root on the target machine.
## Acceptance criteria ## Acceptance criteria
- [ ] The detection file's contents come from a scan of the running machine rather than a guess - [x] The detection file's contents come from a scan of the running machine rather than a guess
- [ ] Filesystem and swap declarations are absent from it, leaving the disk layout as the sole source of those - [x] Filesystem and swap declarations are absent from it, leaving the disk layout as the sole source of those
- [ ] The file no longer describes itself as a placeholder, and says plainly what it holds - [x] The file no longer describes itself as a placeholder, and says plainly what it holds
- [ ] `nix flake check` builds the `neogaia` toplevel - [x] `nix flake check` builds the `neogaia` toplevel
## Implementation Notes
**The guess was wider than the measurement, not narrower.**
It named `thunderbolt`, `usb_storage`, and `sd_mod`, none of which the scan reports; the scan adds `rtsx_pci_sdmmc` for the card reader.
Nothing needed to reach the root device was missing, so the guess was adequate as the task assumed, but it was not accurate.
`sd_mod` survives in the resolved list regardless, supplied by nixpkgs' own defaults; `thunderbolt` and `usb_storage` now genuinely go, and they matter only for booting from external media, which this machine does not do.
**Two further lines from the scan were dropped beyond the filesystem and swap declarations the task named.**
`boot.initrd.luks.devices."cryptroot".device` is derived by the disk layout, which the layout file already states, so keeping it would have created the same duplicate definition the task drops the filesystems to avoid.
`hardware.cpu.intel.updateMicrocode` falls outside the three things the task keeps, and the hardware profile supplies it anyway.
Both were checked rather than assumed: after the change the LUKS device, all four filesystems, and microcode all still resolve.
**The header was rewritten twice.**
Its first form enumerated the file's three attributes, which the repo's comment convention names as a feature inventory and forbids in a file-top header.
It now carries provenance and the absence pointer only.
**Verified by a boot.**
`nix flake check` proves only that the configuration evaluates and builds, so the reduced initrd was exercised on the machine: it unlocked LUKS and mounted the btrfs root unaided.
The running system's store path matches this configuration's build exactly, confirming the boot used it rather than an earlier generation.

View File

@@ -0,0 +1,64 @@
## What to build
Make root-requiring work reachable from an agent session without waiving the password, by sharing sudo's credential cache across sessions and failing loudly when it is cold.
Sudo caches an authentication for a timeout window, but keys that cache by terminal under its default `timestamp_type=tty`.
An agent's commands run in subprocesses on a different terminal, so a `sudo -v` typed in the operator's shell is invisible to them and every privileged command fails.
Setting `timestamp_type=global` keys the cache per user instead, so one authentication covers the whole machine for the window.
The timeout is raised to 60 minutes so a session needing root authenticates once rather than every five.
No `NOPASSWD` rule is introduced, and this is the point of the design.
The password remains genuinely required; only its cache is shared.
A `NOPASSWD` entry for `nixos-rebuild` would be indistinguishable from blanket root on this machine, since anything able to edit the flake and then rebuild it owns the system.
The tradeoff is real and bounded: during the window, any process running as the operator can use the cached credential, not only the agent.
That is acceptable on a single-user personal laptop where the agent already runs as that user, and it is the reason this belongs to a laptop rather than to any future server `Host`.
The second half is failure behaviour.
A cold cache today surfaces as a bare non-zero exit with no output, which reads as an unexplained stall: the operator has to notice the agent is stuck and then work out what it wanted.
A `PreToolUse` hook probing `sudo -n true` turns that into an immediate, actionable refusal naming the command to run.
## Acceptance criteria
- [-] `timestamp_type=global` and a 60-minute timeout are declared as plumbing in the shared base config
- [x] No `NOPASSWD` rule is introduced, and the wheel group still requires a password
- [x] Confirmed that NixOS does not already set `timestamp_type` elsewhere, so the declaration is not silently overridden
- [x] A `PreToolUse` hook in the claude-code `Module` denies a privileged command when the cache is cold, naming `sudo -v` in its message
- [x] The hook's behaviour is correct when the harness sandbox, rather than a cold cache, is what blocks the command
- [x] `nix flake check` builds the `neogaia` toplevel
- [x] Manual confirmation after a rebuild: `sudo -v` in one terminal lets a privileged command succeed from an agent session, and that command fails with the hook's message once the window lapses
## Implementation Notes
**The sudo settings are declared in the claude-code module, not in the shared base config.**
The criterion asking for the shared base contradicted this task's own rationale, which argues the widened cache suits a single-user machine and should not reach a future server.
Neither placement was right, though: the setting and the hook that depends on it belong together.
The hook reads the credential cache from a process of its own, which only works under `timestamp_type=global`, so a host enabling the module without the sudo half would get a hook that never sees a cached credential and refuses every privileged command permanently.
Declaring both under the module's `enable` makes that impossible to get wrong, and carries the setting to any future host that runs the agent.
The cost is that enabling a developer tool now changes the machine's sudo posture, which a reader auditing sudo policy would not expect to find there.
The enable option's description carries the warning so it surfaces in generated documentation.
Should a host ever need the agent without the widened cache, that is when a separate sub-option earns its place; adding one now would be speculative.
**Verified against the running system, in both cache states.**
Cold, the hook refuses with its message; warm, it permits and the command uses a credential authenticated in a different terminal.
The hook process is not itself sandboxed, so it reads the real cache rather than refusing unconditionally — the failure mode that would have required it to fail open instead.
One residual is worth knowing.
The hook governs whether a privileged command is attempted, not whether it can run: the agent's own sandbox blocks `sudo` separately, and swallows it into a bare exit with no output.
A permitted command can therefore still fail for that unrelated reason, and needs the sandbox disabled.
The two are distinguishable in practice, since only one of them produces the hook's message.
**The operator must authenticate from a real terminal.**
Warming the cache from inside an agent session does not work: that shell has no controlling terminal, so sudo cannot prompt and reports `a terminal is required to read the password`.
Feeding the password by another route was rejected rather than unexplored.
Reading it from the agent's stdin would route it through the agent, and an askpass helper on this console-only machine could only prompt on the pane the agent already draws to, which trains the operator to type a password into an agent-controlled surface.
A separate terminal is the only safe channel, which is precisely what the global cache keying exists to make useful.
**`jq` is now a home package.**
The hook parses the tool input handed to it on stdin, and nothing on the profile provided a JSON parser.
Matching on the raw JSON text with `grep` was rejected: a command containing quotes or newlines would break it, and this hook fails closed, so a parsing mistake blocks real work.
**The sudo detection is anchored to command position.**
`grep sudo /etc/passwd` and `echo "run sudo -v"` are allowed; `sudo x`, `cd /tmp && sudo x`, and `true; sudo x` are blocked.
A `sudo` inside a quoted string that happens to sit in command position will still trip the guard, which errs toward asking rather than stalling.

View File

@@ -29,8 +29,9 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
Both were true only while the machine still ran CachyOS against a distro Nix daemon. Both were true only while the machine still ran CachyOS against a distro Nix daemon.
- The substituters a `nix build` fetches from are the **daemon's** (`/etc/nix/nix.conf`), *not* the `nix.settings` of the config being built — those only govern the built system. - The substituters a `nix build` fetches from are the **daemon's** (`/etc/nix/nix.conf`), *not* the `nix.settings` of the config being built — those only govern the built system.
The two coincide here because the dev host runs this flake; they diverge on any machine that does not. The two coincide here because the dev host runs this flake; they diverge on any machine that does not.
- Git identity is not declared in the flake — there is no `programs.git`but commits do work: identity comes from a hand-written `~/.gitconfig` plus this checkout's `.git/config`. - Git identity is not declared in the flake — there is no `programs.git`so it must be set by hand before the first commit on a fresh machine.
Both sit outside the flake, so neither survives a reimage nor reaches another machine; history uses `alexion <contact@alexion.dev>`. The July 2026 reimage confirmed this: it wiped the hand-written `~/.gitconfig`, and the next commit failed with `Author identity unknown`, auto-detecting `alexion@neogaia.(none)`.
It now lives in this checkout's `.git/config`, which reaches no other machine and does not survive the next reimage either; history uses `alexion <contact@alexion.dev>`.
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel); cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`. - The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel); cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
- A flake only sees **git-tracked** files, so a new file that has not been `git add`ed is invisible to evaluation even though it exists on disk. - A flake only sees **git-tracked** files, so a new file that has not been `git add`ed is invisible to evaluation even though it exists on disk.
The failure names the path and reads as if the file were missing: `error: Path 'secrets/shared.yaml' does not exist in Git repository`. The failure names the path and reads as if the file were missing: `error: Path 'secrets/shared.yaml' does not exist in Git repository`.
@@ -39,15 +40,29 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays. That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
- The remote is self-hosted Gitea (`git.alexion.dev`), and the intended CLI is `gitea-axi` rather than `tea`. - The remote is self-hosted Gitea (`git.alexion.dev`), and the intended CLI is `gitea-axi` rather than `tea`.
`gitea-axi` resolves the repository from the `origin` remote and takes credentials from the `axi` tea login, so both are implicit inside a checkout. `gitea-axi` resolves the repository from the `origin` remote and takes credentials from the `axi` tea login, so both are implicit inside a checkout.
**None of it is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh`, no tea login under `~/.config/tea`, and no `GITEA_*` environment — the flake names `gitea-axi` only in the claude-code module's permissions and never packages it. **None of it is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh`, no tea login under `~/.config/tea`, and no `GITEA_*` environment — the flake names `gitea-axi` only as the claude-code module's `SessionStart` hook command and never packages it, so that hook invokes a binary that is not on `PATH`.
Pull requests therefore cannot be opened from this machine until a module provides the tool and its credentials; branches can only be pushed. Pull requests therefore cannot be opened from this machine until a module provides the tool and its credentials; branches can only be pushed.
The earlier claim that `tea` remains installed described the machine while it still ran CachyOS with these tools installed by hand. The earlier claim that `tea` remains installed described the machine while it still ran CachyOS with these tools installed by hand.
- `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store. - `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store.
Editing a skill in place fails; its source is `modules/claude-code/skills/<name>/` here, applied by a rebuild. Editing a skill in place fails; its source is `modules/claude-code/skills/<name>/` here, applied by a rebuild.
Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine. Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine.
Copying out of that tree needs `cp -rL` plus `chmod -R u+w`: a plain `cp -r` copies the symlinks, putting store paths into the destination, and dereferenced files keep the store's read-only mode. Copying out of that tree needs `cp -rL` plus `chmod -R u+w`: a plain `cp -r` copies the symlinks, putting store paths into the destination, and dereferenced files keep the store's read-only mode.
- `home-manager.users.<user>.home.file` is keyed by **absolute** path, not by a path relative to the home directory.
Evaluating `home.file.".claude/CLAUDE.md"` fails with "does not provide attribute"; the working key is `home.file."/home/alexion/.claude/CLAUDE.md"`.
List the real keys with `nix eval --json .#nixosConfigurations.<host>.config.home-manager.users.<user>.home.file --apply builtins.attrNames` rather than guessing one.
A key's `.source` is the input file, whose store path differs from the deployed symlink's target (home-manager copies it to a `hm_`-prefixed path) even though the contents match.
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`); the config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`. - nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`); the config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
- nixpkgs `vimPlugins.nvim-treesitter` tracks the rewritten `main` branch: there is no `require("nvim-treesitter.configs").setup{ensure_installed,highlight,indent}`. Under nixvim, use `plugins.treesitter` with `highlight.enable`/`indent.enable` and `grammarPackages = with config.programs.nixvim.plugins.treesitter.package.builtGrammars; [ ... ]` — the module's own `package.builtGrammars`, **not** `pkgs.vimPlugins.nvim-treesitter.*` (whose query files can mismatch). The module targets the main branch and enables features via neovim-native APIs (`vim.treesitter.start()`, `require'nvim-treesitter'.indentexpr()`). - nixpkgs `vimPlugins.nvim-treesitter` tracks the rewritten `main` branch: there is no `require("nvim-treesitter.configs").setup{ensure_installed,highlight,indent}`. Under nixvim, use `plugins.treesitter` with `highlight.enable`/`indent.enable` and `grammarPackages = with config.programs.nixvim.plugins.treesitter.package.builtGrammars; [ ... ]` — the module's own `package.builtGrammars`, **not** `pkgs.vimPlugins.nvim-treesitter.*` (whose query files can mismatch). The module targets the main branch and enables features via neovim-native APIs (`vim.treesitter.start()`, `require'nvim-treesitter'.indentexpr()`).
- Neovim is configured via **nixvim** (flake input `nixvim`, consumed as `inputs.nixvim.homeModules.nixvim` added to `home-manager.sharedModules`, config under `home-manager.users.<user>.programs.nixvim`). `nixvim.inputs.nixpkgs.follows = "nixpkgs"` is set; nixvim then emits a benign eval warning that its pinned nixpkgs differs from the followed one — builds and runs fine, do not "fix" it by dropping the follows. - Neovim is configured via **nixvim** (flake input `nixvim`, consumed as `inputs.nixvim.homeModules.nixvim` added to `home-manager.sharedModules`, config under `home-manager.users.<user>.programs.nixvim`). `nixvim.inputs.nixpkgs.follows = "nixpkgs"` is set; nixvim then emits a benign eval warning that its pinned nixpkgs differs from the followed one — builds and runs fine, do not "fix" it by dropping the follows.
- To reference the nixvim-built package's own attrs (e.g. treesitter `builtGrammars`) inside our NixOS module, give `home-manager.users.<user>` the module-function form (`hm: { programs.nixvim = { ... hm.config.programs.nixvim... }; }`), since the outer `config` is the NixOS config, not the home-manager one. - To reference the nixvim-built package's own attrs (e.g. treesitter `builtGrammars`) inside our NixOS module, give `home-manager.users.<user>` the module-function form (`hm: { programs.nixvim = { ... hm.config.programs.nixvim... }; }`), since the outer `config` is the NixOS config, not the home-manager one.
- The agent's Bash sandbox blocks `sudo` and swallows it into a bare exit 1 with **no stderr**, which looks identical to the command itself failing.
Re-run with the sandbox disabled to see the real error (`sudo: a password is required`) before diagnosing anything else.
Separately, `nixos-generate-config --show-hardware-config` needs root on this machine even just to print: unprivileged it dies at `Failed to retrieve subvolume info for /`, because the root filesystem is btrfs.
- Sudo's credential cache is keyed per user rather than per terminal (`timestamp_type=global`, 60-minute window, declared by the claude-code module), so an authentication made in one terminal counts for commands the agent runs.
Warming it with `sudo -v` through the agent's own shell — including the `!` prefix — never works: that shell has no controlling terminal, and sudo reports `a terminal is required to read the password`.
It has to be a separate terminal.
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling; a failure *without* that message is the sandbox, not the cache.
- `home-manager.users.<user>` cannot be assigned twice at the same level in one module: `home-manager.users.${user}.home.packages` alongside `home-manager.users.${user}.programs.x` fails with `error: dynamic attribute 'alexion' already defined`.
The interpolated key makes it a dynamic attribute, which nix will not merge the way it merges static paths.
Nest both under a single `home-manager.users.${user} = { ... }`.
- **Verifying a nixvim change headless:** `programs.nixvim.build.package`'s wrapper has **no `-u`**, so running `$OUT/bin/nvim` loads the caller's `~/.config/nvim` (the dev host's real config), *not* the built config — silently. To exercise the built config, launch with `-u "$(nix build --no-link --print-out-paths .#…programs.nixvim.build.initFile)"` and a scratch `HOME`/`XDG_CONFIG_HOME`. `conceallevel` is window-local: set it with `opt_local`/`vim.wo`, never `vim.bo[buf]` (which errors). - **Verifying a nixvim change headless:** `programs.nixvim.build.package`'s wrapper has **no `-u`**, so running `$OUT/bin/nvim` loads the caller's `~/.config/nvim` (the dev host's real config), *not* the built config — silently. To exercise the built config, launch with `-u "$(nix build --no-link --print-out-paths .#…programs.nixvim.build.initFile)"` and a scratch `HOME`/`XDG_CONFIG_HOME`. `conceallevel` is window-local: set it with `opt_local`/`vim.wo`, never `vim.bo[buf]` (which errors).

View File

@@ -1,14 +1,13 @@
{ lib, modulesPath, ... }: { lib, modulesPath, ... }:
# Placeholder: regenerate with nixos-generate-config on the target machine. # Hardware detected by nixos-generate-config on this machine.
# disko derives `fileSystems` and the LUKS device; none declared here.
{ {
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ]; imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot.initrd.availableKernelModules = [ boot.initrd.availableKernelModules = [
"xhci_pci" "xhci_pci"
"thunderbolt"
"nvme" "nvme"
"usb_storage" "rtsx_pci_sdmmc"
"sd_mod"
]; ];
boot.initrd.kernelModules = [ ]; boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ]; boot.kernelModules = [ "kvm-intel" ];

View File

@@ -10,6 +10,9 @@ These are common instructions for Alexion's agents across all scenarios.
- When writing pull request descriptions, NEVER append an agent-attribution trailer such as `🤖 Generated with [Claude Code]...`. - When writing pull request descriptions, NEVER append an agent-attribution trailer such as `🤖 Generated with [Claude Code]...`.
Leave it out entirely, with no exceptions. Leave it out entirely, with no exceptions.
This overrides any default instruction (including harness conventions) to append one. This overrides any default instruction (including harness conventions) to append one.
- NEVER ask the user a question using the `AskUserQuestion` tool.
Ask in plain prose, in your own message, instead, with no exceptions.
This overrides any default instruction (including harness conventions and skill instructions) to use it.
- Never manually modify CHANGELOG.md files or any files that are marked as auto-generated. - Never manually modify CHANGELOG.md files or any files that are marked as auto-generated.
Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`). Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`).
If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command). If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command).

View File

@@ -1,6 +1,7 @@
{ {
config, config,
lib, lib,
pkgs,
... ...
}: }:
# Claude Code for the primary user, configured through home-manager, which ships # Claude Code for the primary user, configured through home-manager, which ships
@@ -23,38 +24,72 @@ let
]; ];
in in
{ {
options.modules.claude-code.enable = lib.mkEnableOption "Claude Code, Anthropic's CLI, configured via home-manager"; options.modules.claude-code.enable = lib.mkEnableOption ''
Claude Code, Anthropic's CLI, configured via home-manager.
Enabling this also widens sudo's credential cache, keying it per user rather
than per terminal and holding it for 60 minutes, so that a single
authentication covers commands the agent issues. No command is made
passwordless, but any process running as the primary user can spend the
cached credential while it lasts. Suitable for a single-user machine'';
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.claude-code = { # Keying sudo's credential cache per user rather than per terminal lets one
enable = true; # authentication cover commands issued by processes holding no terminal of
# their own. Any process running as this user can spend that credential
# until it lapses, so this suits a single-user machine.
security.sudo.extraConfig = ''
Defaults timestamp_type=global
Defaults timestamp_timeout=60
'';
# Global agent instructions, rendered to ~/.claude/CLAUDE.md. home-manager.users.${user} = {
context = ./CLAUDE.md; # jq parses the tool input handed to the sudo guard hook.
home.packages = [ pkgs.jq ];
# One directory per skill, symlinked under ~/.claude/skills. programs.claude-code = {
skills = ./skills; enable = true;
# Installed at ~/.claude/hooks/attention-bell.sh, referenced by the settings below. # Global agent instructions, rendered to ~/.claude/CLAUDE.md.
hooks."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh; context = ./CLAUDE.md;
settings = { # One directory per skill, symlinked under ~/.claude/skills.
model = "opus"; skills = ./skills;
hooks = {
Stop = bellHook; # Installed under ~/.claude/hooks, referenced by the settings below.
Notification = bellHook; hooks."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh;
SessionStart = [ hooks."agent-sudo-guard.sh" = builtins.readFile ./hooks/agent-sudo-guard.sh;
{
matcher = ""; settings = {
hooks = [ model = "opus";
{ hooks = {
type = "command"; Stop = bellHook;
command = "gitea-axi"; Notification = bellHook;
timeout = 10; PreToolUse = [
} {
]; matcher = "Bash";
} hooks = [
]; {
type = "command";
command = "~/.claude/hooks/agent-sudo-guard.sh";
timeout = 10;
}
];
}
];
SessionStart = [
{
matcher = "";
hooks = [
{
type = "command";
command = "gitea-axi";
timeout = 10;
}
];
}
];
};
}; };
}; };
}; };

View File

@@ -0,0 +1,29 @@
#!/bin/sh
# agent-sudo-guard.sh — refuse a privileged command while sudo's credential
# cache is cold, naming the command that warms it.
#
# Commands arrive here from subprocesses holding no terminal, so an uncached
# sudo fails with a bare non-zero exit and no output, reading as an unexplained
# stall. The probe below reads a cache keyed per user rather than per terminal,
# so an authentication made in the operator's own terminal counts.
input=$(cat)
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
# Anchored to a command position so a `sudo` appearing as an argument or inside
# a string does not trip the guard.
if ! printf '%s' "$command" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*sudo([[:space:]]|$)'; then
exit 0
fi
if sudo -n true 2>/dev/null; then
exit 0
fi
# Exit 2 blocks the call and feeds stderr back to the agent.
echo 'Blocked: sudo has no cached credential, and this command cannot answer a password prompt.
Ask the operator to run `sudo -v` in their own terminal, then retry.
Never attempt to supply a password directly.
If this still blocks immediately after the operator runs `sudo -v`, the cache is
not the cause: check that this hook can reach sudo at all.' >&2
exit 2