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:
@@ -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;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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