diff --git a/.claude/tasks/0018-agent-sudo-credential-caching.md b/.claude/tasks/0018-agent-sudo-credential-caching.md new file mode 100644 index 0000000..782fcc1 --- /dev/null +++ b/.claude/tasks/0018-agent-sudo-credential-caching.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 32a6e55..d79d698 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,4 +55,14 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla - 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..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.` 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.` 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). diff --git a/modules/claude-code/claude-code.nix b/modules/claude-code/claude-code.nix index e47b02a..d0ae9b8 100644 --- a/modules/claude-code/claude-code.nix +++ b/modules/claude-code/claude-code.nix @@ -1,6 +1,7 @@ { config, lib, + pkgs, ... }: # Claude Code for the primary user, configured through home-manager, which ships @@ -23,38 +24,72 @@ let ]; 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 { - home-manager.users.${user}.programs.claude-code = { - enable = true; + # Keying sudo's credential cache per user rather than per terminal lets one + # 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. - context = ./CLAUDE.md; + home-manager.users.${user} = { + # jq parses the tool input handed to the sudo guard hook. + home.packages = [ pkgs.jq ]; - # One directory per skill, symlinked under ~/.claude/skills. - skills = ./skills; + programs.claude-code = { + enable = true; - # Installed at ~/.claude/hooks/attention-bell.sh, referenced by the settings below. - hooks."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh; + # Global agent instructions, rendered to ~/.claude/CLAUDE.md. + context = ./CLAUDE.md; - settings = { - model = "opus"; - hooks = { - Stop = bellHook; - Notification = bellHook; - SessionStart = [ - { - matcher = ""; - hooks = [ - { - type = "command"; - command = "gitea-axi"; - timeout = 10; - } - ]; - } - ]; + # One directory per skill, symlinked under ~/.claude/skills. + skills = ./skills; + + # Installed under ~/.claude/hooks, referenced by the settings below. + hooks."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh; + hooks."agent-sudo-guard.sh" = builtins.readFile ./hooks/agent-sudo-guard.sh; + + settings = { + model = "opus"; + hooks = { + Stop = bellHook; + Notification = bellHook; + 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; + } + ]; + } + ]; + }; }; }; }; diff --git a/modules/claude-code/hooks/agent-sudo-guard.sh b/modules/claude-code/hooks/agent-sudo-guard.sh new file mode 100755 index 0000000..29ba861 --- /dev/null +++ b/modules/claude-code/hooks/agent-sudo-guard.sh @@ -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