feat(claude-code): share sudo's credential cache across sessions (task 0018)

Key sudo's credential cache per user rather than per terminal, holding it
for 60 minutes. An authentication made in the operator's own terminal then
covers commands issued by processes holding no terminal of their own, which
previously failed with a bare non-zero exit and no output.

No command is made passwordless. The password remains required; only the
cache holding it is shared, and any process running as the primary user can
spend that credential until it lapses.

A PreToolUse hook refuses a privileged command while the cache is cold,
naming the command that warms it, so the condition announces itself rather
than presenting as a stall. Both states were exercised against the running
system.
This commit is contained in:
2026-07-19 22:18:58 -04:00
parent d0bfc7b21b
commit 54c191f801
4 changed files with 164 additions and 26 deletions

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

@@ -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.<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.
- 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).

View File

@@ -1,6 +1,7 @@
{
config,
lib,
pkgs,
...
}:
# Claude Code for the primary user, configured through home-manager, which ships
@@ -23,10 +24,30 @@ 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 = {
# 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
'';
home-manager.users.${user} = {
# jq parses the tool input handed to the sudo guard hook.
home.packages = [ pkgs.jq ];
programs.claude-code = {
enable = true;
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
@@ -35,14 +56,27 @@ in
# One directory per skill, symlinked under ~/.claude/skills.
skills = ./skills;
# Installed at ~/.claude/hooks/attention-bell.sh, referenced by the settings below.
# 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 = "";
@@ -59,4 +93,5 @@ in
};
};
};
};
}

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