Making sudo Work with AI Agents
I have been doing a lot of system/network admin lately using Claude Code on my NixOS config files. It has been amazing since I do not like the Nix language, but I love the Nix experience. Now I can really cruse on trying new things and implementing production fixes. I really need to write that update to Nix - Death by a thousand cuts. So much has changed in the past year. One of the nice thinks about Nix is I can do a full system build as my user, without switching to it. This way the agent can try fixing syntax and the like until the build passes. Then I can manually to the rebuild switch, which requires sudo access. However there are times when I want the agent to do the switch itself - and sometimes often. For example when I was troubleshooting a tailscale dns issue I had it try a bunch of different configs until I could get the communication working. In this case I don't want the agent to be constantly asking me to run the manual switch. So how do I work around this issue of running sudo commands? Can I have the agent stop and ask for my password? Today, coding agents like Claude Code and OpenCode share a fundamental limitation: they can't handle interactive terminal prompts. When a command needs Here's what I tried, what exists in the community, and the solution I landed on. This is an industry-wide problem. Gemini CLI has open issues. Claude Code has a dozen+ related issues on GitHub. Cursor has forum threads about it. No tool has a built-in solution. Here's what the community has come up with: The most commonly recommended approach: make specific commands passwordless. This works but requires pre-enumerating every command you'll need. In practice, you either end up with The most developed community solution is GlassOnTin/secure-askpass (and its corrected fork by crypdick). It encrypts your sudo password with your SSH key using The agent's command gets rewritten from I actually tried this approach early on — writing a Claude Code hook that rewrote Issue #28930 on the Claude Code repo proposes using polkit for privilege escalation: This is the most security-conscious proposal — crash-safe, time-bounded, using OS-native auth. But it's Linux-only, requires polkit and systemd, and isn't built yet. A system-level approach where sudo authenticates via your SSH agent instead of passwords. Well-established for Ansible automation, but there's a known bug where Issue #29275 proposes a shell watcher: run a loop as root in a spare terminal that watches for command files Claude writes, executes them, and writes output back. Simple but hacky — file-based IPC has race conditions and it's only suitable for single-user dev machines. I wanted something that: The solution has three pieces. The key insight: Now when you run Claude Code hooks let you run scripts before tool calls. A The tricky part: how do you both show the user a message AND wait for them to act? Hook stderr isn't displayed in Claude's terminal. Only the My solution: a deny-then-poll pattern using a state file. $HOME/.claude/hooks/sudo-check.sh The flow: The I do use Opus a lot these days, but I have no allegiance to it. I want to see how this would work with a more open agent. The same approach works in OpenCode, which uses a TypeScript plugin system instead of shell script hooks. OpenCode plugins implement Drop this in The OpenCode version is simpler because async hooks can just Every Bash command goes through the hook. The Claude Code has a history of dangerous sudo interactions. The auto-updater once suggested This hook fires even with Built-in support from Claude Code: Issue #9881 requests PTY support via All of this can be found in my public configs. Here are permalinks to the relevant files: Claude Code hook - claude-sudo-check.sh OpenCode plugin - opencode-sudo-check.tssudo, there's no TTY to type your password into. The agent's subprocess just fails:sudo: a terminal is required to read the password
The Landscape: What Others Have Tried
NOPASSWD in sudoers
your_username ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart my-app.service
NOPASSWD: ALL (too permissive) or constantly editing sudoers when you need a new command.SUDO_ASKPASS with SSH Key Encryption
age (for Ed25519) or OpenSSL (for RSA), then provides a GUI confirmation dialog for each sudo invocation.sudo foo to SUDO_ASKPASS=/path/to/askpass sudo -A foo, which triggers the helper instead of a terminal prompt.sudo to sudo -A with an askpass helper. It works on GUI workstations, but it's complex: you need the encrypted password file, the decryption helper, a GUI dialog library, and the hook to tie it all together. For a personal dev machine, it felt like overkill. Also, I want this to work on terminal only intefaces without a GUI.polkit/pkexec with Auto-Expiry
pkexec <grant-script> which triggers a native OS password dialogsystemd transient timer auto-revokes access after N minutespam_ssh_agent_auth
sudo -n (non-interactive mode) still prompts for a password even when the SSH agent should handle it — which is exactly the mode AI agents need.File-Based sudo Watcher
What I Built
1. Global sudo Timestamp
sudo -v caches your credentials, but by default the cache is per-TTY. Claude's subprocess runs in a different TTY, so it never sees the cache. In my sudo config setting Defaults timestamp_type=global makes the credential cache shared across all sessions.sudo -v in any terminal, Claude's subprocesses can use the cached credentials for 10 minutes.2. The Hook: Deny-Then-Poll
PreToolUse hook on Bash can inspect the command and allow or deny it.permissionDecisionReason from a JSON deny response is visible. But if you deny, the command doesn't run and the user has to manually tell Claude to retry.#!/usr/bin/env sh
# Claude Code PreToolUse hook for Bash commands.
# Blocks sudo commands when credentials aren't cached.
# First attempt: denies immediately with a message.
# Subsequent attempts: polls for up to 30s for credentials.
STATEFILE="/tmp/claude-sudo-hook-notified"
INPUT=$(cat)
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
# Only check commands that contain sudo
if ! printf '%s' "$COMMAND" | grep -qw 'sudo'; then
exit 0
fi
# Test if sudo credentials are cached
if sudo -n true 2>/dev/null; then
rm -f "$STATEFILE"
exit 0
fi
# First time: deny with message so it's visible in the terminal
if [ ! -f "$STATEFILE" ]; then
touch "$STATEFILE"
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Sudo credentials not cached. Please run sudo -v in another terminal. Retrying automatically..."}}'
exit 0
fi
# Subsequent attempts: poll for up to 30s
ELAPSED=0
while ! sudo -n true 2>/dev/null; do
sleep 1
ELAPSED=$((ELAPSED + 1))
if [ "$ELAPSED" -ge 30 ]; then
rm -f "$STATEFILE"
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Sudo credentials not cached. Timed out after 30s. Please run sudo -v in another terminal and retry."}}'
exit 0
fi
done
rm -f "$STATEFILE"
exit 0
sudo -v in another terminal. Retrying automatically..."sudo -v in another terminal — within 30 seconds, the hook detects the cached credentials and the command proceeds-qw flag on grep ensures word-boundary matching — so a path like /run/sudo/ts/ doesn't false-positive trigger the sudo check.3. Porting to OpenCode
tool.execute.before and throw an error to block a command — no JSON protocol needed.import type { Plugin } from "@opencode-ai/plugin"
export const SudoCheck: Plugin = async ({ $ }) => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool !== "bash") return
if (!/\bsudo\b/.test(output.args.command)) return
// Check if sudo credentials are cached
try {
await $`sudo -n true 2>/dev/null`
return
} catch {}
// Poll for up to 30 seconds waiting for the user to run sudo -v
console.error(
"Sudo credentials not cached. Run 'sudo -v' in another terminal. Waiting up to 30s...",
)
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000))
try {
await $`sudo -n true 2>/dev/null`
return
} catch {}
}
throw new Error(
"Sudo credentials not cached. Timed out after 30s. Please run sudo -v in another terminal.",
)
},
}
}~/.config/opencode/plugins/sudo-check.ts and OpenCode picks it up automatically.await in a loop — no state file or deny-then-retry dance needed. But the core logic is identical: check for sudo, test sudo -n true, poll, timeout. The two implementations can't share a single file since the hook interfaces are fundamentally different (shell script with JSON stdin/stdout vs TypeScript async functions), but they're small enough that maintaining both is trivial.Things to Know
matcher config only filters by tool name, not command content. The script exits immediately for non-sudo commands (one grep, negligible overhead), but there's no way to avoid invoking it.timestamp_type=global has security implications. Any process on the machine can use the cached credentials during the timeout window. This is fine for a personal dev machine. For shared systems, consider the polkit approach or NOPASSWD for specific commands instead.sudo chown -R $USER /usr which bricked multiple users' systems by breaking sudo's setuid bit. Sudo-elevated child processes can also crash the terminal or cause unbounded disk growth. The hook adds a layer of protection here too — you're always aware when sudo is being used.--dangerously-skip-permissions. Hooks are the last line of defense. I use an alias cl = "sudo -v && claude --dangerously-skip-permissions" that pre-caches credentials and skips permission prompts, with the hook as the safety net.node-pty, which would solve sudo prompts at the root. Until then, hooks are the best we've got.The Code