SLOPSHOPPER

pkexec-guard

Routes `sudo` in Bash calls to a password prompt the person can answer: pkexec on a Linux desktop, an osascript administrator dialog on macOS, and Windows…

newguardtoastprocess
v0.2.0no licenseupdated 2026-10-08joshkerr/claude-mods/pkexec-guard
A shopper browsing a rack in a slop shop
README

claude-mods

Three small Claude Code mods (hooks-module plugins) by Josh Kerr. Each runs on Linux, macOS and Windows.

ModWhat it does
pkexec-guardRoutes sudo in Bash calls to a password prompt you can actually answer. Claude Code's Bash tool cannot answer sudo's password prompt, so a plain sudo hangs or fails. On a Linux desktop sudo becomes pkexec (a polkit dialog); on macOS it becomes osascript -e 'do shell script "…" with administrator privileges' (the system administrator dialog); on Windows it is left to Windows sudo, which raises a UAC prompt. A headless Linux box keeps plain sudo. sudo with flags (-n, -u, -E, …) is refused with a hint rather than guessed at.
long-task-notifierA live timer in the status line for Bash commands and background tasks that run longer than a threshold, then a toast, a chime and a desktop notification when they finish. Linux uses paplay and notify-send, macOS uses afplay and Notification Center, Windows uses a system sound and a toast. Threshold, chime and notification are user options.
herdr-statuslineA band above the prompt showing the herdr workspace and pane you are in (when Claude Code runs inside herdr), the working directory, and the git branch with its dirty, ahead and behind state.

Install

In a terminal Claude Code session:

/plugin install pkexec-guard --marketplace joshkerr/claude-mods
/plugin install long-task-notifier --marketplace joshkerr/claude-mods
/plugin install herdr-statusline --marketplace joshkerr/claude-mods

Answer y to add the marketplace the first time, then pick the user scope. Or from a shell:

claude plugin marketplace add joshkerr/claude-mods
claude plugin install pkexec-guard@claude-mods --scope user
claude plugin install long-task-notifier@claude-mods --scope user
claude plugin install herdr-statusline@claude-mods --scope user

Later, claude plugin marketplace update claude-mods then claude plugin update <mod> pulls a new version.

Develop

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and the hooks module under hooks/. To run them from this checkout instead of an install, point CLAUDE_CODE_PLUGIN_DIRS at the mod folders (:-separated on Linux and macOS, ; on Windows), or pass claude --plugin-dir <folder>.

claude plugin validate <mod>   # manifest, hooks and what the engine would refuse
claude plugin test <mod>       # the *.test.ts beside the mod
tsc -p <mod>                   # after the mod has loaded once, which writes its types

The .claude-plugin/types/ folder inside each mod is written by the engine when the mod loads and is not committed.

Source 1 files
hooks/register.ts 134 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3/** How this host can raise privileges with a password prompt the person can answer. */
4export type Elevator = 'pkexec' | 'osascript' | 'windows-sudo' | 'none'
5
6export type Platform = 'linux' | 'darwin' | 'win32'
7
8// The engine follows `$` only into functions declared in the hooks module itself,
9// so the OS probe lives here rather than in a shared file.
10let os: Platform | undefined
11
12/** The host OS: `OS=Windows_NT` on Windows, else what `uname -s` answers (Darwin, or Linux). */
13const platform = async ($: EngineInterface): Promise<Platform> => {
14  if (os !== undefined) return os
15  if ((await $.env.get('OS')) === 'Windows_NT') return (os = 'win32')
16  try {
17    const r = await $.process.run(['uname', '-s'], { timeoutMs: 3000 })
18    if (r.exitCode === 0 && r.stdout.trim() === 'Darwin') return (os = 'darwin')
19  } catch {
20    // no uname: call it Linux
21  }
22  return (os = 'linux')
23}
24
25// `sudo` in command position: at the start, or after ; & | ( or a newline.
26// (A `sudo` inside a quoted string or a comment is not a command and is left alone
27// by requiring it to lead a statement.)
28const LEAD = String.raw`(^|[;&|(]\s*|\n\s*)`
29const SUDO_ANY = new RegExp(`${LEAD}sudo(\\s|$)`)
30const SUDO_FLAGGED = new RegExp(`${LEAD}sudo\\s+-`)
31const SUDO_PLAIN = new RegExp(`${LEAD}sudo\\s+(?=[^-\\s])`, 'g')
32
33const DENY_FLAGS: Record<'pkexec' | 'osascript', string> = {
34  pkexec:
35    'pkexec-guard: sudo cannot prompt for a password in this session, and sudo flags ' +
36    '(-n, -u, -E, …) do not map onto pkexec. Put the root steps in a script and run it ' +
37    'with `pkexec <script>`, which opens a graphical password dialog.',
38  osascript:
39    'pkexec-guard: sudo cannot prompt for a password in this session, and sudo flags ' +
40    '(-n, -u, -E, …) do not map onto an administrator dialog. Put the root steps in a script ' +
41    'and run it with `osascript -e \'do shell script "<script>" with administrator privileges\'`, ' +
42    'which opens a macOS password dialog.',
43}
44
45const TOAST: Record<Elevator, string | undefined> = {
46  pkexec: 'sudo → pkexec: a password dialog will open on your desktop',
47  osascript: 'sudo → osascript: a macOS administrator dialog will open',
48  'windows-sudo': 'sudo: a Windows UAC prompt will open',
49  none: undefined,
50}
51
52export const hasSudo = (command: string): boolean => SUDO_ANY.test(command)
53
54/** Where the statement starting at `from` ends: the first unquoted ; & | ) or newline. */
55export const statementEnd = (s: string, from: number): number => {
56  let quote: '"' | "'" | undefined
57  for (let i = from; i < s.length; i++) {
58    const c = s[i]
59    if (quote === "'") {
60      if (c === "'") quote = undefined
61    } else if (quote === '"') {
62      if (c === '\\') i++
63      else if (c === '"') quote = undefined
64    } else if (c === '\\') i++
65    else if (c === "'" || c === '"') quote = c
66    else if (c === ';' || c === '&' || c === '|' || c === ')' || c === '\n') return i
67  }
68  return s.length
69}
70
71/** One statement as `osascript -e 'do shell script "…" with administrator privileges'`, shell-quoted. */
72export const adminWrap = (stmt: string): string => {
73  const inner = stmt.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
74  const line = `do shell script "${inner}" with administrator privileges`
75  return `osascript -e '${line.replace(/'/g, String.raw`'\''`)}'`
76}
77
78export const rewrite = (command: string, elevator: Elevator = 'pkexec'): { command: string } | { deny: string } => {
79  // Windows sudo raises its own UAC prompt, and a host with no dialog to offer is left alone.
80  if (elevator === 'none' || elevator === 'windows-sudo' || !SUDO_ANY.test(command)) return { command }
81  if (SUDO_FLAGGED.test(command)) return { deny: DENY_FLAGS[elevator] }
82  if (elevator === 'pkexec') return { command: command.replace(SUDO_PLAIN, '$1pkexec ') }
83
84  // macOS: each `sudo <statement>` becomes one administrator-privileges call.
85  let out = ''
86  let at = 0
87  const re = new RegExp(SUDO_PLAIN.source, 'g')
88  for (let m = re.exec(command); m !== null; m = re.exec(command)) {
89    const start = m.index + m[0].length
90    const end = statementEnd(command, start)
91    const raw = command.slice(start, end)
92    const stmt = raw.trimEnd()
93    out += command.slice(at, m.index) + (m[1] ?? '') + adminWrap(stmt) + raw.slice(stmt.length)
94    at = end
95    re.lastIndex = end
96  }
97  return { command: out + command.slice(at) }
98}
99
100/** The host's elevator, found once: Windows sudo, osascript, pkexec on a Linux desktop, or nothing. */
101export const detect = async ($: EngineInterface): Promise<Elevator> => {
102  const host = await platform($)
103  if (host === 'win32') return 'windows-sudo'
104  if (host === 'darwin') return 'osascript'
105  // pkexec draws its dialog on a desktop; a headless box keeps plain sudo (NOPASSWD or not).
106  const display = (await $.env.get('DISPLAY')) || (await $.env.get('WAYLAND_DISPLAY'))
107  if (!display) return 'none'
108  try {
109    const r = await $.process.run(['which', 'pkexec'], { timeoutMs: 3000 })
110    return r.exitCode === 0 ? 'pkexec' : 'none'
111  } catch {
112    return 'none'
113  }
114}
115
116let elevator: Promise<Elevator> | undefined
117
118export const register: Register = on => {
119  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
120    if (!hasSudo(e.command)) return next(e)
121    const how = await (elevator ??= detect($))
122    const r = rewrite(e.command, how)
123    if ('deny' in r) return { deny: r.deny }
124
125    const toast = TOAST[how]
126    if (toast !== undefined) $.ui.toast(toast, { timeoutMs: 8000 })
127    return r.command === e.command ? next(e) : next({ ...e, command: r.command })
128  }).catch(($, e, next) =>
129    next.called
130      ? next(e)
131      : { deny: `${$.plugin.name}: its guard failed. Elevate by hand (pkexec, an osascript administrator dialog, or Windows sudo) instead of plain sudo here.` },
132  )
133}
134