fix(neogaia): replace guessed hardware detection with a real scan (task 0017) #2
64
.claude/tasks/0018-agent-sudo-credential-caching.md
Normal file
64
.claude/tasks/0018-agent-sudo-credential-caching.md
Normal 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.
|
||||||
10
CLAUDE.md
10
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()`).
|
- 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).
|
||||||
|
|||||||
@@ -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,10 +24,30 @@ 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
|
||||||
|
# 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;
|
enable = true;
|
||||||
|
|
||||||
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
|
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
|
||||||
@@ -35,14 +56,27 @@ in
|
|||||||
# One directory per skill, symlinked under ~/.claude/skills.
|
# One directory per skill, symlinked under ~/.claude/skills.
|
||||||
skills = ./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."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh;
|
||||||
|
hooks."agent-sudo-guard.sh" = builtins.readFile ./hooks/agent-sudo-guard.sh;
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
model = "opus";
|
model = "opus";
|
||||||
hooks = {
|
hooks = {
|
||||||
Stop = bellHook;
|
Stop = bellHook;
|
||||||
Notification = bellHook;
|
Notification = bellHook;
|
||||||
|
PreToolUse = [
|
||||||
|
{
|
||||||
|
matcher = "Bash";
|
||||||
|
hooks = [
|
||||||
|
{
|
||||||
|
type = "command";
|
||||||
|
command = "~/.claude/hooks/agent-sudo-guard.sh";
|
||||||
|
timeout = 10;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
SessionStart = [
|
SessionStart = [
|
||||||
{
|
{
|
||||||
matcher = "";
|
matcher = "";
|
||||||
@@ -59,4 +93,5 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
29
modules/claude-code/hooks/agent-sudo-guard.sh
Executable file
29
modules/claude-code/hooks/agent-sudo-guard.sh
Executable 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
|
||||||
Reference in New Issue
Block a user