Lets Claude run commands that ask for a password (sudo, ssh, git): a pane inside Claude Code asks you, and the password never reaches the model

A Claude Code mod that lets Claude run commands that need sudo, instead of copying them into your own terminal.
When a command Claude runs asks for a password (sudo apt install, an install script that calls sudo, git push over HTTPS, an ssh key passphrase), cc-askpass opens a password pane inside Claude Code where you can type it. The password goes straight to the program that asked. It never reaches the model, the transcript or any log.
https://github.com/user-attachments/assets/bbb11cc1-cbfb-4b46-ba88-23fbf1ab6254
Without it, Claude's Bash tool has no terminal, so sudo fails at once:
sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is required
So you end up running the command in your own terminal. Pasting the password into the chat or adding NOPASSWD to sudoers also gets around it, but both are riskier.
Needs Claude Code 2.1.286 or later, on Linux, WSL, macOS or Windows. Run these in Claude Code:
/plugin marketplace add GoneTone/cc-askpass
/plugin install cc-askpass@cc-askpass
/plugin install asks where to install it and shows its options; the defaults are fine. Then restart Claude Code or run /reload-plugins.
When a command Claude runs asks for a password, the pane opens. Its $ line shows the command that asked; for sudo, that is the command that will run as root (for example $ sudo apt update). Asked by: names the program that asked. If the prompt came from something else Claude ran (such as ./install.sh), Claude ran: shows that command. Answer only if these are what you expect: type the password and press Enter.
Claude ran: or $ line is cut short, and shows both lines in full; Show less cuts them again.sudo is waiting for a password, with an Open password prompt button. Click the button or type /askpass; both work while Claude is busy. Outside fullscreen mode, press ctrl+x tab then Enter instead of clicking.Username for '...' and ssh's (yes/no) host-key question.Password prompts on hosts reached with ssh use the same pane: ssh host sudo apt update, sudo inside a remote script (ssh host ./deploy.sh), ssh from that host on to another one, and rsync, ansible or scripts that call ssh. A remote sudo run with -S (password from standard input) or -n (never ask), as ansible's become does, does not use the pane.
The pane names the host (sudo on server needs your password) and marks the $ line (reported by server), since that command comes from the remote host and cannot be checked locally. It always shows Claude ran:, with a warning to check it. Type that host's password, never a local one.
Some hosts cannot send prompts back: network devices, accounts with a forced command, Windows sshd, and hosts without /bin/sh. cc-askpass detects them, and the command still runs, but prompts on those hosts do not reach the pane. git over ssh (git push to git@github.com) is left alone entirely.
Every ssh command cc-askpass handles opens a new connection of its own. It does not reuse a shared connection set up with ControlMaster/ControlPersist in your ~/.ssh/config, nor a caller's own ControlPath or -S (so tools such as ansible open an extra connection for each command). If logging in to a host takes a password or a two-factor code, you are therefore asked for it on every command. To avoid that, add the host to excludeHosts: ssh to it then runs with your original arguments, so your own shared connection works again, but sudo prompts on that host no longer reach the pane. Each handled command also starts a few extra login shells on the host.
Two options control this. Set them with /plugin configure cc-askpass@cc-askpass in Claude Code, or in your settings:
remoteHosts (default on): show password prompts from remote hosts in the pane. Turn it off with "pluginConfigs": { "cc-askpass@cc-askpass": { "options": { "remoteHosts": false } } }.excludeHosts: glob patterns matched against the host name. cc-askpass leaves ssh to a matching host alone.Every command Claude runs is a new process, so sudo asks every time. cc-askpass does not cache passwords, since any program running as you could read the cache. Instead, you can have sudo remember that you already entered it: run sudo visudo -f /etc/sudoers.d/cc-askpass and add:
Defaults:YOUR_USER timestamp_type=global, timestamp_timeout=5
sudo then shares one timestamp across all your terminals and Claude Code sessions, and asks again only after 5 minutes.
This weakens what cc-askpass protects. Until the timestamp expires:
Turn it on only if you accept that. Keep the timeout short, and run sudo -k when you are done to end it at once. Without timestamp_timeout, sudo uses its default: 5 minutes, or 15 on some distributions (Ubuntu, for example).
sudo.exe and UAC prompts are not handled; they show their own Windows dialog.win/ with the .NET Framework built into Windows 10 and 11. Nothing binary is downloaded or shipped. The build goes to win/out/ and takes a moment.Add-Type or unsigned programs (AppLocker, WDAC, an enforced execution policy), cc-askpass stays off, and a toast says why.ssh host … opens two connections, one for the prompts and one for the command. A password login, or a key with a passphrase and no agent, is therefore asked twice per command; agent logins and keys without a passphrase notice nothing.Asked by: comes from the operating system where possible. On Linux and macOS it relies on the process ID that cc-askpass's helper reports, so a program that sends its request just as a real sudo prompt appears could be shown as that sudo. On Windows the kernel reports the process ID.ssh or git prompt, the password goes to the server named in that command.su and doas cannot use askpass. Use sudo.pkexec, run0, systemctl as a normal user) fails instead of asking: systemctl with "Interactive authentication required", pkexec with "Error creating textual authentication agent".Do you want to continue? [Y/n]) are not handled. Use flags such as -y or --no-interaction.DISPLAY=:0 that cc-askpass sets may make a graphical program try to reach an X server that is not there, and fail.claude -p, the SDK) cc-askpass does nothing, since nobody is there to answer; sudo fails as it would without it.The "no new privileges" flag is set, which prevents sudo from running as root, on macOS with operation not permitted: sudo). If you let Claude run the command outside the sandbox, the pane asks as usual.SSH_ASKPASS_PROMPT=confirm) appear as an ordinary text prompt./usr/bin/sudo) does not use the pane: it fails for lack of a terminal, or reads the terminal under ssh -t. Call it as plain sudo..bashrc or .zshenv) prints text when a command runs, a welcome message for example, cc-askpass cannot confirm that the host supports it. The command still runs, but sudo there does not reach the pane. To fix it, print that text only in interactive shells, or send it to stderr.-o "User alice" or -i "/keys/my key", cc-askpass leaves that ssh command alone, so sudo on the remote host does not reach the pane. For -o, drop the space: -o User=alice.cc-askpass has two parts: a mod inside Claude Code that draws the pane, and a small helper program (bin/askpass) that sudo and ssh run to ask for a password.
Claude's Bash command → sudo → bin/askpass → request FIFO → cc-askpass mod → pane
↑ │
└──────────── reply FIFO ←─────────────────┘
When a session starts, the mod starts a listener (bin/askpass --serve) and sets SUDO_ASKPASS and SSH_ASKPASS for the commands Claude runs (plus DISPLAY=:0 if it was unset, since sudo only uses askpass when DISPLAY is set). sudo then runs bin/askpass, which hands the prompt to the mod through a FIFO in a private directory (mode 0700). The mod uses ps to find which program started the helper, shows the pane, and writes your answer into that helper's own FIFO. It also tells Claude in its system prompt that sudo can ask for a password, so Claude runs sudo instead of sudo -n.
For ssh, the mod puts a small ssh wrapper first on the PATH of the commands Claude runs. The wrapper checks that the host supports this and sets up a relay, so a prompt raised there (remote sudo, or ssh on to another host) comes back to the same pane. On hosts that fail the check, the command still runs, without remote prompts.
On Windows the helper is one .exe that talks to the mod over a named pipe only your user can open.
Run these in Claude Code:
/plugin uninstall cc-askpass@cc-askpass
/plugin marketplace remove cc-askpass
The second line removes the marketplace that the install added. If you added the sudoers file, remove it with sudo rm /etc/sudoers.d/cc-askpass.
claude --plugin-dir .
This starts Claude Code with the plugin loaded from this directory. After you change a file, run /reload-plugins or restart Claude Code. On Windows the helper is rebuilt by itself when win/cc-askpass.cs changes, since each build goes to win/out/<hash of the source>.
claude plugin validate .
claude plugin test .
validate checks the plugin's files. test runs the mod's tests (hooks/*.test.ts) inside Claude Code's test kit; it needs no login and works on every platform.
sh tests/askpass.sh # Linux, WSL, macOS
sh tests/ssh.sh # Linux, WSL, macOS
tests/askpass.sh tests the helper. tests/ssh.sh tests the ssh wrapper; its end-to-end part starts its own sshd on 127.0.0.1 as your user, so it needs sshd installed (openssh-server on Debian and Ubuntu). Without it those tests are skipped with a warning. It also checks that every shell it finds (sh, bash, zsh, fish) reads the remote setup command the same way, and uses python3 for a few socket checks when available.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/win.ps1
Run it from the repository root on Windows. It compiles the helper and tests it.
npx -p typescript tsc -p .
tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes the first time it loads the mod, so run claude --plugin-dir . once before this. That directory is git-ignored.
GitHub Actions runs validate and test on Ubuntu, macOS and Windows, then tests/askpass.sh and tests/ssh.sh on Ubuntu and macOS, and tests/win.ps1 on Windows. A failure on one platform does not stop the others.
.gitattributes).tests/win.ps1 ASCII: Windows PowerShell reads a script without a BOM in the ANSI code page.hooks/register.tsx 978 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { askedByLine, type AskSource, type Requester, type WinRequest } from './core'
4import {
5 AnsweredPids,
6 applyMaskedInput,
7 bashShellArgs,
8 bashSshFunction,
9 isBashSshFunction,
10 claudePidOf,
11 clipLines,
12 matchBashCommand,
13 parsePsTable,
14 isOurHelper,
15 parseExePath,
16 parseHelperPs,
17 parseParentPs,
18 parseReady,
19 parseRequest,
20 parseWinLine,
21 parseWinReady,
22 promptLabel,
23 relayHost,
24 remoteLabel,
25 stripControl,
26 withShim,
27 withoutShim,
28 wrappedLines,
29 shouldMask,
30 splitLines,
31 fnv1a64,
32 winClaudeCommand,
33 winMsysShell,
34 winRequester,
35} from './core'
36
37const READY_TIMEOUT_MS = 5000
38const REPLY_TIMEOUT_MS = 5000
39const BUILD_TIMEOUT_MS = 60_000
40const ALIVE_POLL_MS = 2000
41// Models add `-n` to sudo to avoid prompts they expect to hang; while the listener runs,
42// the system prompt says sudo can ask instead.
43const SUDO_NOTE =
44 'sudo can ask the user for a password in this session: the cc-askpass plugin shows a password ' +
45 'prompt inside Claude Code whenever sudo (or ssh/git) needs one. Run sudo commands normally. ' +
46 'Do not add `-n` or `-S`, do not pipe a password into sudo, and do not ask the user to run sudo ' +
47 'themselves. If the user cancels or the password is wrong, sudo fails as usual; report that.'
48// Windows has no sudo: only ssh and git ask there.
49const WINDOWS_NOTE =
50 'ssh and git can ask the user for a password or key passphrase in this session: the cc-askpass plugin shows a ' +
51 'prompt inside Claude Code. Run them normally and do not ask the user to type passwords themselves.'
52// Added while remote prompts are on: models add `-t` or ask the user to log in themselves.
53const REMOTE_NOTE =
54 ' sudo on hosts reached with `ssh` asks through the same prompt: run `ssh host sudo …` directly, ' +
55 'without `-t` or `-tt`, and do not ask the user to log in to the host themselves.'
56// Shown under the Claude line of every remote pane (spec §8): a process running as the user
57// can pose as a relay, so the person is the last check.
58const REMOTE_WARNING =
59 'A remote prompt can be raised by any program running as you. Answer only if you expected this command to need a password on this host.'
60// Shown in red under `Asked by:` when the request did not come through our helper.
61const UNVERIFIED_WARNING =
62 "This request did not come through cc-askpass's helper. Answer only if you know which program is asking."
63// Characters of a remote host chain the heading shows; the verified host at its end always stays.
64const MAX_LABEL_LENGTH = 120
65// Characters of a remote program's name the heading shows.
66const MAX_PROGRAM_LENGTH = 40
67// A relayed host chain as the wrapper writes it: host names joined by ` via `.
68const VIA_PATTERN = /^[A-Za-z0-9._@:%-]+( via [A-Za-z0-9._@:%-]+)*$/
69// Time for the band to drop its button and hand the keyboard back (verified at 150 ms).
70const BAND_RELEASE_MS = 150
71const PANE = 'askpass'
72// Width the pane asks for when docked, and the width its content is measured at.
73const PANE_WIDTH = 60
74// Rows the `$` and "Claude ran" lines may take: a long command must not push the field away.
75const MAX_SECTION_ROWS = 3
76// Writes only into a FIFO: the first test skips a vanished helper, the second catches
77// the FIFO being removed between that test and the open (`<>` would create a file).
78// Read-write so the open never blocks.
79const REPLY_SCRIPT = '[ -p "$1" ] || exit 1; exec 3<>"$1"; [ -p /dev/fd/3 ] || { rm -f "$1"; exit 1; }; cat >&3'
80
81type Request = {
82 id: number
83 prompt: string
84 requester: Requester
85 isRetry: boolean
86 // The Bash tool command that led to this request, when it says more than the sudo line.
87 claudeCommand: string | null
88 // Set before the first await of an answer, so Enter, Esc and the alive poll answer once.
89 isAnswered?: boolean
90 // Windows: the listener generation it came from; its id means nothing to any other listener.
91 generation?: number
92}
93
94// Module state. A hot reload starts it over and kills the serve child with the module.
95// The password lives only in `typed`, and only while its pane is open.
96let askpassPath = ''
97let sessionDir = ''
98// Native Windows (spec §5): a built helper and a named pipe instead of `sh`, `ps` and FIFOs.
99let isWin = false
100// Windows: the build directory, and the listener's pipe, reply token and own image path once it is ready.
101let winOutDir = ''
102// Windows: the store key of the person's own exported `ssh` function, put back when ours goes.
103// In the store, not a module variable: after a hot reload the env holds ours, not theirs.
104const INHERITED_BASH_SSH = 'inheritedBashSsh'
105let winListener = { pipe: '', token: '', exe: '' }
106// Windows requests whose asker went away; the listener says so instead of `kill -0`.
107const goneIds = new Set<number>()
108// The commands of PowerShell tool calls running now: the tool puts them on no command line.
109const runningPowerShell: string[] = []
110let didSetDisplay = false
111// True while the listener runs and the env points at it.
112let isListening = false
113// Bumped whenever a serve loop is replaced, so the old loop's end is not taken for a crash.
114let serveGeneration = 0
115let stopServe = () => {}
116// The running listener's loop, done once it has taken back what it set.
117let serving: Promise<void> = Promise.resolve()
118const queue: Request[] = []
119let current: Request | null = null
120let isAdvancing = false
121let typed = ''
122let isMasked = true
123// True once the person asks to see the clipped command lines whole.
124let isExpanded = false
125// True while the current request waits for the person to open its pane (narrow terminal).
126let isWaitingForOpen = false
127let aliveTimer: Timer | null = null
128const answered = new AnsweredPids()
129// New on every load of this module. A reload shows the same pending requests again, and an
130// Input keeps the text it held under its key while the new module knows none of it.
131const LOAD_ID = Math.random().toString(36).slice(2, 8)
132// Claude's latest Bash commands, to name the one a request comes from. Background commands
133// outlive their tool call, so these are kept by count, not by call.
134const recentBashCommands: string[] = []
135const RECENT_BASH_LIMIT = 50
136// The plugin's options, read in `register`; a change reloads the module.
137let isRemoteOn = true
138let excludeHosts: readonly string[] = []
139
140function log($: EngineInterface, text: string): void {
141 void $.ui.log(`cc-askpass: ${text}`, { to: 'debug' })
142}
143
144// Requests are checked one at a time, so the queue keeps the order they arrived in.
145let checking: Promise<void> = Promise.resolve()
146
147/** A line of the listener of `generation`, checked behind the lines before it. */
148function onRequestLine($: EngineInterface, line: string, generation: number): void {
149 // Windows ids restart at 1 with each listener: a replaced one's lines would name the new one's requests.
150 const isStale = () => isWin && generation !== serveGeneration
151 checking = checking
152 .then(() => (isStale() ? undefined : isWin ? verifyWin($, line, generation) : verify($, line)))
153 .catch(error => {
154 // ps missing (a slim container) or failing: say so, and let the helper fail now.
155 $.ui.toast(`cc-askpass: could not check an askpass request: ${String(error)}`)
156 log($, `request failed: ${String(error)}`)
157 if (isStale()) return
158 const parsed = isWin ? parseWinLine(line) : parseRequest(line)
159 if (parsed !== null && !('gone' in parsed)) void reply($, 'id' in parsed ? parsed.id : parsed.helperPid, '')
160 })
161}
162
163/** Queues a Windows request: the listener already named who connected. */
164async function verifyWin($: EngineInterface, line: string, generation: number): Promise<void> {
165 const parsed = parseWinLine(line)
166 if (parsed === null) return log($, `ignored malformed request line: ${line}`)
167 if ('gone' in parsed) {
168 // The alive poll and the queue drop it.
169 goneIds.add(parsed.gone)
170 return
171 }
172 // The kernel names a client by its resolved image path, as it named the listener itself.
173 const requester = winRequester(parsed, winListener.exe)
174 const request: Request = {
175 id: parsed.id,
176 prompt: parsed.prompt,
177 requester,
178 isRetry: false,
179 claudeCommand: null,
180 generation,
181 }
182 return enqueue($, request, parsed.relay?.via ?? null, parsed.relay?.reported ?? null, parsed.ancestors)
183}
184
185/** Works out who asks with `ps` and queues the request; no caller is refused, the person decides. */
186async function verify($: EngineInterface, line: string): Promise<void> {
187 const parsed = parseRequest(line)
188 if (parsed === null) return log($, `ignored malformed request line: ${line}`)
189
190 const helperPs = await $.process.run(['ps', '-ww', '-o', 'ppid=,args=', '-p', String(parsed.helperPid)])
191 const helper = parseHelperPs(helperPs.stdout)
192 if (helper === null) return
193 const id = parsed.helperPid
194 let requester: Requester
195 if (!isOurHelper(helper.args, askpassPath)) {
196 log($, `request from pid ${id} did not come through our helper: ${helper.args}`)
197 requester = {
198 pid: id,
199 isSudo: false,
200 comm: 'unknown program',
201 command: stripControl(helper.args),
202 askedBy: { kind: 'unverified', exe: null },
203 }
204 } else {
205 const parentPs = await $.process.run(['ps', '-ww', '-o', 'user=,comm=,args=', '-p', String(helper.ppid)])
206 const parent = parseParentPs(parentPs.stdout, helper.ppid)
207 if (parent === null) return
208 requester = { ...parent, askedBy: await programOf($, parent) }
209 }
210 const request: Request = { id, prompt: parsed.prompt, requester, isRetry: false, claudeCommand: null }
211 return enqueue($, request, parsed.via, parsed.reported, [])
212}
213
214/**
215 * Cleans a relayed request's remote text, names the command Claude ran and queues the request.
216 * `via` and `reported` are a relay's raw fields; `ancestors` the Windows client's.
217 */
218async function enqueue(
219 $: EngineInterface,
220 request: Request,
221 via: string | null,
222 reported: string | null,
223 ancestors: WinRequest['ancestors'],
224): Promise<void> {
225 const { requester } = request
226 if (requester.askedBy.kind === 'relay') {
227 // With remote prompts off no relay is taken: the helper is ours, so an empty answer makes it fail at once.
228 if (!isRemoteOn) {
229 log($, 'rejected a remote request: remoteHosts is off')
230 return reply($, request.id, '')
231 }
232 // Everything but the host is remote text: it may not drive the terminal.
233 const clean = (text: string | null) => (text === null ? null : stripControl(text) || null)
234 // The wrapper writes host names only: any other chain is dropped, not shown.
235 const hosts = via !== null && VIA_PATTERN.test(via) ? via : null
236 requester.remote = { host: requester.askedBy.host, via: hosts, reported: clean(reported) }
237 }
238 // Any program can raise a pane, so no prompt may drive the terminal either.
239 request.prompt = stripControl(request.prompt)
240
241 const claudeCommand = await findClaudeCommand($, requester, ancestors)
242 // A local line that repeats the sudo command says nothing new; a remote pane always names it.
243 const isRepeat = requester.remote === undefined && claudeCommand?.trim() === requester.command.trim()
244 request.claudeCommand = isRepeat ? null : claudeCommand
245 const key = retryKey(request)
246 request.isRetry = key !== null && answered.has(key, await $.clock.now())
247 queue.push(request)
248 // The checks are done: a pane that fails to open leaves the request for /askpass.
249 void showNext($).catch(error => log($, `could not show the request: ${String(error)}`))
250}
251
252/** What the helper's parent is: our ssh wrapper's relay, or a program named by its executable. */
253async function programOf($: EngineInterface, parent: Omit<Requester, 'askedBy'>): Promise<AskSource> {
254 const host = relayHost(parent.command, `${$.plugin.root}/bin/shim/ssh`)
255 if (host !== null) return { kind: 'relay', host, realSsh: null }
256 // The path of the parent's executable (bin/exe-of).
257 const exe = await $.process.run(['sh', `${$.plugin.root}/bin/exe-of`, String(parent.pid)])
258 const path = exe.exitCode === 0 ? parseExePath(exe.stdout) : null
259 return { kind: 'program', exe: stripControl(path ?? (parent.isSudo ? 'sudo (root)' : parent.comm)) }
260}
261
262/** The command Claude ran that led to `requester`: on Windows from the client's `ancestors`. */
263async function findClaudeCommand(
264 $: EngineInterface,
265 requester: Requester,
266 ancestors: WinRequest['ancestors'],
267): Promise<string | null> {
268 if (isWin) {
269 const command = winClaudeCommand(ancestors, recentBashCommands, runningPowerShell)
270 return command ?? (await winMsysClaudeCommand($, ancestors))
271 }
272 const claudePid = claudePidOf(sessionDir)
273 if (claudePid === null || recentBashCommands.length === 0) return null
274 try {
275 const ps = await $.process.run(['ps', '-e', '-ww', '-o', 'pid=,ppid=,args='])
276 const shell = bashShellArgs(parsePsTable(ps.stdout), requester.pid, claudePid)
277 return shell === null ? null : matchBashCommand(shell, recentBashCommands)
278 } catch (error) {
279 // Only context for the pane: a failure here must not block the request.
280 log($, `could not find the Bash command: ${String(error)}`)
281 return null
282 }
283}
284
285/** A Bash call whose Windows ancestors stop at a script Git Bash forked: found by the MSYS parents. */
286async function winMsysClaudeCommand($: EngineInterface, ancestors: WinRequest['ancestors']): Promise<string | null> {
287 const sh = winMsysShell(ancestors)
288 if (sh === null || recentBashCommands.length === 0) return null
289 try {
290 const pids = ancestors.toReversed().map(({ pid }) => String(pid))
291 const walk = await $.process.run([sh, `${$.plugin.root}\\bin\\msys-parents`, ...pids])
292 for (const line of walk.stdout.split('\n').toReversed()) {
293 const command = matchBashCommand(line, recentBashCommands)
294 if (command !== null) return command
295 }
296 return null
297 } catch (error) {
298 // Only context for the pane: a failure here must not block the request.
299 log($, `could not walk the MSYS parents: ${String(error)}`)
300 return null
301 }
302}
303
304async function isAlive($: EngineInterface, pid: number): Promise<boolean> {
305 // The shell's builtin kill: /bin/kill may not be installed.
306 return (await $.process.run(['sh', '-c', 'kill -0 "$1"', '_', String(pid)])).exitCode === 0
307}
308
309/** A relayed request also ends with its relay: nothing is left to carry the answer back. */
310async function isRequestAlive($: EngineInterface, request: Request): Promise<boolean> {
311 // The Windows listener reports a gone asker, relay included: its connection is the request.
312 // An ended or replaced listener took every connection with it.
313 if (isWin) return isListening && request.generation === serveGeneration && !goneIds.has(request.id)
314 if (!(await isAlive($, request.id))) return false
315 return request.requester.remote === undefined || isAlive($, request.requester.pid)
316}
317
318/**
319 * What answered requests are remembered by, to tell a retry; only sudo asks again for the
320 * same thing. Locally sudo's pid. A relayed line carries only the remote helper's pid, new on
321 * every ask, so a remote sudo goes by its relay (one per ssh command), the command the remote
322 * side reported and the prompt.
323 */
324function retryKey(request: Request): number | string | null {
325 const { remote, isSudo, pid } = request.requester
326 if (remote === undefined) return isSudo ? pid : null
327 if (remoteProgram(remote.reported) !== 'sudo') return null
328 return `${remote.host}:${pid}:${remote.reported}:${request.prompt}`
329}
330
331/** The program a remote request names: the reported command's first word, or "a program". */
332function remoteProgram(reported: string | null): string {
333 const first = (reported ?? '').split(' ')[0] ?? ''
334 return (first.split('/').at(-1) ?? '').slice(0, MAX_PROGRAM_LENGTH) || 'a program'
335}
336
337/** The remote host chain, its start cut when long: the verified host at its end stays. */
338function hostLabel(remote: NonNullable<Requester['remote']>): string {
339 const label = remoteLabel(remote.host, remote.via)
340 return label.length > MAX_LABEL_LENGTH ? `…${label.slice(1 - MAX_LABEL_LENGTH)}` : label
341}
342
343/** Who waits for the password, for the band. */
344function askerName(requester: Requester): string {
345 const { remote } = requester
346 return remote === undefined ? requester.comm : `${remoteProgram(remote.reported)} on ${remote.host}`
347}
348
349function isMaskedByDefault(request: Request): boolean {
350 const { remote, isSudo } = request.requester
351 // A remote sudo is masked whatever its prompt says, as sudo is locally.
352 return shouldMask(remote === undefined ? isSudo : remoteProgram(remote.reported) === 'sudo', request.prompt)
353}
354
355function paneTitle(requester: Requester): string {
356 if (requester.remote !== undefined) return `Password for ${requester.remote.host}`
357 return requester.isSudo ? 'sudo password' : `Password requested by ${requester.comm} (pid ${requester.pid})`
358}
359
360/** The terminal outside fullscreen reports no clicks, so hints there name keys only. */
361function isMainScreen(e: { viewport?: { isFullscreen?: boolean } }): boolean {
362 return e.viewport?.isFullscreen === false
363}
364
365function focusWarning(isMain: boolean): string {
366 const how = isMain ? 'Press ctrl+x tab' : 'Click this pane (or press ctrl+x tab)'
367 return `${how} before typing — your keys are going to the prompt below.`
368}
369
370function askedByText(requester: Requester): string {
371 return `Asked by: ${askedByLine(requester.askedBy)}`
372}
373
374function isUnverified(requester: Requester): boolean {
375 return requester.askedBy.kind === 'unverified'
376}
377
378function claudeLine(request: Request, width = PANE_WIDTH, maxRows = MAX_SECTION_ROWS): string | null {
379 // A remote pane always has the line: what Claude Code recorded is the one thing no process
380 // can change, and "(unknown)" says nothing vouches for the request.
381 const command = request.claudeCommand ?? (request.requester.remote === undefined ? null : '(unknown)')
382 if (command === null) return null
383 return clipLines(`Claude ran: ${command}`, maxRows, width)
384}
385
386function commandLine(request: Request, width = PANE_WIDTH, maxRows = MAX_SECTION_ROWS): string {
387 const { remote } = request.requester
388 if (remote === undefined) return clipLines(`$ ${request.requester.command}`, maxRows, width)
389 if (remote.reported === null) return `$ (not reported by ${remote.host})`
390 // Only the reported text is cut: who reported it must stay in view.
391 const suffix = ` (reported by ${remote.host})`
392 const room = Math.max(1, maxRows * width - suffix.length)
393 const line = `$ ${remote.reported}`
394 return `${line.length > room ? `${line.slice(0, room - 1)}…` : line}${suffix}`
395}
396
397/** True when the Claude or `$` line is cut, so the pane offers them whole. */
398function isClipped(request: Request, width = PANE_WIDTH): boolean {
399 return (
400 claudeLine(request, width) !== claudeLine(request, width, Infinity) ||
401 commandLine(request, width) !== commandLine(request, width, Infinity)
402 )
403}
404
405/**
406 * Rows the pane body needs, measured at PANE_WIDTH (an inline pane is usually wider, so this
407 * errs tall). The focus warning is counted always: whether it shows is known only when drawn.
408 */
409function paneRows(request: Request): number {
410 const claude = claudeLine(request)
411 // The `remote` tag and the warning under the Claude line.
412 const remote = request.requester.remote === undefined ? 0 : 1 + wrappedLines(REMOTE_WARNING, PANE_WIDTH)
413 const header =
414 wrappedLines(paneHeading(request.requester), PANE_WIDTH) +
415 wrappedLines(askedByText(request.requester), PANE_WIDTH) +
416 (isUnverified(request.requester) ? wrappedLines(UNVERIFIED_WARNING, PANE_WIDTH) : 0) +
417 remote +
418 (claude === null ? 0 : wrappedLines(claude, PANE_WIDTH)) +
419 wrappedLines(commandLine(request), PANE_WIDTH) +
420 (isClipped(request) ? 1 : 0)
421 const field = (request.isRetry ? 1 : 0) + wrappedLines(promptLabel(request.prompt), PANE_WIDTH) + 1
422 const blankLines = 3
423 return wrappedLines(focusWarning(false), PANE_WIDTH) + header + field + 1 + blankLines
424}
425
426function paneHeading(requester: Requester): string {
427 const { remote } = requester
428 if (remote !== undefined) {
429 const program = remoteProgram(remote.reported)
430 return `${program} on ${hostLabel(remote)} needs ${program === 'sudo' ? 'your' : 'a'} password`
431 }
432 return requester.isSudo ? 'sudo needs your password' : `${requester.comm} (pid ${requester.pid}) needs a password`
433}
434
435function clearWaiting($: EngineInterface): void {
436 if (!isWaitingForOpen) return
437 isWaitingForOpen = false
438 $.ui.invalidate('ui.render')
439}
440
441async function openPane($: EngineInterface): Promise<void> {
442 if (current === null) return
443 const opened = await $.ui.open({
444 id: PANE,
445 title: paneTitle(current.requester),
446 focus: true,
447 closeOnEscape: true,
448 // Sizes the body wants: rows when inline above the prompt, columns when docked.
449 rows: paneRows(current),
450 columns: PANE_WIDTH,
451 })
452 if (opened.isPlaced) return clearWaiting($)
453 // An open the person did not ask for waits on a narrow terminal. The band's button and
454 // /askpass are opens they ask for, which are placed at any width.
455 isWaitingForOpen = true
456 $.ui.invalidate('ui.render')
457}
458
459/**
460 * Opens the pane from the band's button. While the band holds the keyboard a pane's focus
461 * request is refused, so the band is cleared first and the pane opens once it has let go.
462 * The press handler returns this promise: an open after the press is over counts as one
463 * the person did not ask for, and waits unplaced on a narrow terminal.
464 */
465async function openFromBand($: EngineInterface): Promise<void> {
466 clearWaiting($)
467 await $.clock.sleep(BAND_RELEASE_MS)
468 await openPane($)
469}
470
471/** Shows the next queued request whose helper is still alive; one caller at a time. */
472async function showNext($: EngineInterface): Promise<void> {
473 if (current !== null || isAdvancing) return
474 isAdvancing = true
475 try {
476 for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
477 if (!(await isRequestAlive($, next))) continue
478 const request = next
479 current = request
480 typed = ''
481 isMasked = isMaskedByDefault(request)
482 isExpanded = false
483 // A pane still open from before a reload is not drawn again by `ui.open`.
484 $.ui.invalidate('ui.render')
485 aliveTimer = $.clock.every(ALIVE_POLL_MS, () => {
486 void isRequestAlive($, request)
487 .then(alive => (alive || request.isAnswered ? undefined : finish($, request)))
488 .catch(error => log($, `alive check failed: ${String(error)}`))
489 })
490 await openPane($)
491 return
492 }
493 } finally {
494 isAdvancing = false
495 }
496 // A request queued while the loop above was finishing its last check.
497 if (current === null && queue.length > 0) await showNext($)
498}
499
500/** Ends `request` (answered or not) and moves on to the next one; a no-op once it ended. */
501async function finish($: EngineInterface, request: Request): Promise<void> {
502 if (current !== request) return
503 aliveTimer?.cancel()
504 aliveTimer = null
505 current = null
506 typed = ''
507 clearWaiting($)
508 await $.ui.close({ id: PANE })
509 await showNext($)
510}
511
512/**
513 * Answers Windows request `id` through `cc-askpass.exe --reply`, which hands it to the listener's
514 * reply pipe. Exit 0 means written, not accepted: a wrong token or an ended request is dropped there.
515 */
516async function winReply($: EngineInterface, id: number, password: string): Promise<void> {
517 // The reply is one line: a line break would cut the password short, so it is sent as a cancel.
518 if (/[\r\n]/.test(password)) {
519 log($, `the password for request ${id} had a line break; answered empty`)
520 password = ''
521 }
522 try {
523 const result = await $.process.run([askpassPath, '--reply'], {
524 stdin: `${winListener.token}\t${id}\n${password}\n`,
525 env: { CC_ASKPASS_PIPE: winListener.pipe },
526 timeoutMs: REPLY_TIMEOUT_MS,
527 })
528 if (result.exitCode !== 0) log($, `reply to request ${id} failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`)
529 } catch (error) {
530 log($, `reply to request ${id} failed: ${String(error)}`)
531 }
532}
533
534async function reply($: EngineInterface, id: number, password: string): Promise<void> {
535 if (isWin) return winReply($, id, password)
536 try {
537 await $.process.run(['sh', '-c', REPLY_SCRIPT, '_', `${sessionDir}/reply.${id}`], {
538 stdin: `${password}\n`,
539 timeoutMs: REPLY_TIMEOUT_MS,
540 })
541 } catch (error) {
542 log($, `reply to pid ${id} failed: ${String(error)}`)
543 }
544}
545
546async function submit($: EngineInterface, request: Request, password: string): Promise<void> {
547 if (current !== request || request.isAnswered) return
548 request.isAnswered = true
549 await reply($, request.id, password)
550 const key = retryKey(request)
551 if (key !== null) answered.mark(key, await $.clock.now())
552 await finish($, request)
553}
554
555async function setEnv($: EngineInterface): Promise<void> {
556 // Where the helper finds the listener goes first. Windows has no sudo and no DISPLAY check.
557 if (isWin) {
558 await $.env.set('CC_ASKPASS_PIPE', winListener.pipe)
559 } else {
560 await $.env.set('CC_ASKPASS_DIR', sessionDir)
561 await $.env.set('SUDO_ASKPASS', askpassPath)
562 }
563 await $.env.set('SSH_ASKPASS', askpassPath)
564 await $.env.set('SSH_ASKPASS_REQUIRE', 'force')
565 // Read by bin/shim/ssh, one glob per line.
566 await $.env.set('CC_ASKPASS_EXCLUDE', excludeHosts.join('\n'))
567 // remoteHosts: the wrapper stays on PATH all session and checks this on every call.
568 await $.env.set('CC_ASKPASS_WRAP', isRemoteOn ? '1' : undefined)
569 if (isWin) return
570 // sudo only uses askpass when DISPLAY is set; a real display is left alone. The marker
571 // survives a hot reload (module variables do not), so ownership of :0 is not forgotten.
572 const display = await $.env.get('DISPLAY')
573 const isOurs = display === ':0' && (await $.env.get('CC_ASKPASS_SET_DISPLAY')) === '1'
574 if (display === undefined || isOurs) {
575 await $.env.set('DISPLAY', ':0')
576 await $.env.set('CC_ASKPASS_SET_DISPLAY', '1')
577 didSetDisplay = true
578 }
579}
580
581async function unsetEnv($: EngineInterface): Promise<void> {
582 isListening = false
583 if (isWin) {
584 await $.env.set('CC_ASKPASS_PIPE', undefined)
585 } else {
586 await $.env.set('CC_ASKPASS_DIR', undefined)
587 await $.env.set('SUDO_ASKPASS', undefined)
588 }
589 await $.env.set('SSH_ASKPASS', undefined)
590 await $.env.set('SSH_ASKPASS_REQUIRE', undefined)
591 await $.env.set('CC_ASKPASS_EXCLUDE', undefined)
592 await $.env.set('CC_ASKPASS_WRAP', undefined)
593 if (didSetDisplay) {
594 await $.env.set('DISPLAY', undefined)
595 await $.env.set('CC_ASKPASS_SET_DISPLAY', undefined)
596 didSetDisplay = false
597 }
598}
599
600/**
601 * The Windows helper's build directory, `win\out\<hash of its source>`, compiled first when it
602 * holds no `cc-askpass.exe`; otherwise why it could not be built (a missing source counts too).
603 */
604async function buildHelper($: EngineInterface, root: string): Promise<{ outDir: string } | { error: string }> {
605 try {
606 const source = `${root}\\win\\cc-askpass.cs`
607 const outDir = `${root}\\win\\out\\${fnv1a64(await $.fs.read(source))}`
608 if (await $.fs.exists(`${outDir}\\cc-askpass.exe`)) return { outDir }
609 // Windows PowerShell 5.1, whatever the tools run: its Add-Type is the .NET Framework compiler.
610 const systemRoot = (await $.env.get('SystemRoot')) ?? 'C:\\Windows'
611 const powershell = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
612 // A run that throws (a timeout) is a failed build like any other.
613 const build = await $.process
614 .run(
615 [powershell, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${root}\\win\\build.ps1`, source, outDir],
616 { timeoutMs: BUILD_TIMEOUT_MS },
617 )
618 .catch((error: unknown) => ({ exitCode: -1, stderr: String(error) }))
619 // A build of the same source by another session starting at once may have finished it.
620 if (build.exitCode === 0 || (await $.fs.exists(`${outDir}\\cc-askpass.exe`))) return { outDir }
621 // build.ps1 prints one line; PowerShell's own errors put the reason first.
622 const reason = build.stderr.trim().split(/\r?\n/)[0]?.replace(/^cc-askpass: build failed: /, '')
623 return { error: reason || `exit code ${build.exitCode}` }
624 } catch (error) {
625 return { error: String(error) }
626 }
627}
628
629/**
630 * Why the built helper cannot run (AppLocker, WDAC or an antivirus blocks it), or null when it
631 * runs: bare and without a listener, a working helper says CC_ASKPASS_PIPE is not set.
632 */
633async function helperRunError($: EngineInterface, exe: string): Promise<string | null> {
634 try {
635 const run = await $.process.run([exe], { env: { CC_ASKPASS_PIPE: '' }, timeoutMs: REPLY_TIMEOUT_MS })
636 if (run.exitCode === 1 && run.stderr.includes('CC_ASKPASS_PIPE is not set')) return null
637 return run.stderr.trim().split(/\r?\n/)[0] || `exit code ${run.exitCode}`
638 } catch (error) {
639 return String(error)
640 }
641}
642
643/**
644 * Builds the Windows helper, checks that it runs and gives bash our ssh; false, toasted, when
645 * either failed: a shim that cannot run would break every ssh.
646 */
647async function startWindows($: EngineInterface): Promise<boolean> {
648 const built = await buildHelper($, $.plugin.root)
649 if ('error' in built) {
650 $.ui.toast(`cc-askpass: could not build the Windows helper: ${built.error}`)
651 return false
652 }
653 const error = await helperRunError($, `${built.outDir}\\cc-askpass.exe`)
654 if (error !== null) {
655 $.ui.toast(`cc-askpass: could not run the Windows helper: ${error}`)
656 return false
657 }
658 // A second start replaces a running listener only now that its own helper runs. The old one
659 // ends first: ending later, it could take back the shim set below.
660 if (serveGeneration > 0) {
661 serveGeneration++
662 stopServe()
663 // Its last env writes land before they are taken back.
664 await serving
665 await endWinListener($)
666 }
667 winOutDir = built.outDir
668 const before = await $.env.get('BASH_FUNC_ssh%%')
669 // An earlier load's function is ours, and the person's is already in the store; anything
670 // else is the person's and comes back later.
671 if (before === undefined) await $.store.delete(INHERITED_BASH_SSH)
672 else if (!isBashSshFunction(before)) await $.store.set(INHERITED_BASH_SSH, before)
673 // Git Bash's profile puts its /usr/bin (and its ssh) before the inherited PATH, so every bash
674 // imports this as an `ssh` function instead; the two variables stop it rewriting `/x` arguments
675 // into Windows paths.
676 await $.env.set('BASH_FUNC_ssh%%', bashSshFunction(`${winOutDir}\\shim\\ssh.exe`))
677 return true
678}
679
680/**
681 * Takes our ssh back off PATH and out of bash once the listener never started or ended. The Bash
682 * tool's snapshot keeps the function, where the wrapper passes through: CC_ASKPASS_WRAP is unset.
683 */
684async function removeWinShim($: EngineInterface): Promise<void> {
685 const inherited = await $.store.get(INHERITED_BASH_SSH)
686 await $.env.set('BASH_FUNC_ssh%%', typeof inherited === 'string' ? inherited : undefined)
687 await $.env.set('PATH', withoutShim(await $.env.get('PATH'), `${winOutDir}\\shim`, ';'))
688}
689
690/**
691 * Drops every Windows request: they went with the listener that held their connections, and no
692 * `gone` line will come. A new listener numbers its requests from 1; the askers send theirs anew.
693 */
694async function dropWinRequests($: EngineInterface): Promise<void> {
695 goneIds.clear()
696 queue.length = 0
697 if (current !== null) await finish($, current)
698}
699
700/** Everything the Windows listener set, and its requests, gone with it. */
701async function endWinListener($: EngineInterface): Promise<void> {
702 // Its env only once it was ready: an inherited SSH_ASKPASS it never replaced stays.
703 if (isListening) await unsetEnv($)
704 await dropWinRequests($)
705 await removeWinShim($)
706}
707
708async function runServe($: EngineInterface): Promise<void> {
709 stopServe()
710 const generation = ++serveGeneration
711 askpassPath = isWin ? `${winOutDir}\\cc-askpass.exe` : `${$.plugin.root}/bin/askpass`
712 if (isWin) await dropWinRequests($)
713 const serve = $.process.spawn({ argv: [askpassPath, isWin ? 'serve' : '--serve', await $.session.id()] })
714 stopServe = () => void serve.return(undefined as never)
715
716 let isReady = false
717 let isDone = false
718 let didGiveUp = false
719 let lastError = ''
720 const notStarted = () => $.ui.toast(`cc-askpass: the askpass listener did not start${lastError && `: ${lastError}`}`)
721 // Never cancelled, only guarded: a cancelled timer slows every later `settle()` of the test
722 // kit's mock clock by a second (Claude Code 2.1.296).
723 $.clock.after(READY_TIMEOUT_MS, () => {
724 if (isReady || isDone || generation !== serveGeneration) return
725 didGiveUp = true
726 notStarted()
727 stopServe()
728 })
729
730 let rest = ''
731 try {
732 for await (const chunk of serve) {
733 if (chunk.stream !== 'stdout') {
734 lastError = chunk.text.trim()
735 log($, chunk.text)
736 continue
737 }
738 const split = splitLines(rest, chunk.text)
739 rest = split.rest
740 for (const line of split.lines) {
741 if (isReady) {
742 onRequestLine($, line, generation)
743 continue
744 }
745 const ready = isWin ? parseWinReady(line) : parseReady(line)
746 if (ready === null) continue
747 isReady = true
748 if (typeof ready === 'string') sessionDir = ready
749 else winListener = ready
750 await setEnv($)
751 isListening = true
752 }
753 }
754 } catch (error) {
755 log($, `askpass listener failed: ${String(error)}`)
756 }
757 isDone = true
758
759 if (generation !== serveGeneration) return
760 if (!isReady) {
761 if (!didGiveUp) notStarted()
762 if (isWin) await removeWinShim($)
763 return
764 }
765 if (isWin) await endWinListener($)
766 else await unsetEnv($)
767 $.ui.toast(`cc-askpass: the askpass listener stopped; ${isWin ? 'ssh' : 'sudo'} will not ask for passwords here`)
768}
769
770export const register: Register = (on, options) => {
771 isRemoteOn = options.remoteHosts !== false
772 const globs: unknown = options.excludeHosts
773 excludeHosts = Array.isArray(globs) ? globs.filter((glob): glob is string => typeof glob === 'string') : []
774
775 on('session.start', async ($, e, next) => {
776 // Under -p or the SDK nobody can answer a pane: leave sudo to fail at once as before.
777 if (!e.isInteractive) return next(e)
778 isWin = (await $.env.get('OS')) === 'Windows_NT'
779 // Before the session's shell snapshot, which fixes PATH for every Bash command: a command
780 // that changed PATH itself would lose the permission dialog's "don't ask again".
781 if (isWin && !(await startWindows($))) return next(e)
782 const shim = isWin ? `${winOutDir}\\shim` : `${$.plugin.root}/bin/shim`
783 const path = withShim(await $.env.get('PATH'), shim, isWin ? ';' : ':')
784 if (path !== null) await $.env.set('PATH', path)
785 const started = await next(e)
786 await $.command.register({
787 name: 'askpass',
788 description: 'Open the pending password prompt',
789 immediate: true,
790 })
791 serving = runServe($).catch(error => log($, `askpass listener failed: ${String(error)}`))
792 return started
793 })
794
795 on('prompt.compose', async ($, e, next) => {
796 const composed = await next(e)
797 if (!isListening) return composed
798 const note = isWin ? WINDOWS_NOTE : SUDO_NOTE
799 const text = isRemoteOn ? note + REMOTE_NOTE : note
800 return { sections: [...composed.sections, { id: 'cc-askpass:sudo', text, scope: 'session' as const }] }
801 })
802
803 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
804 if (e.tool === 'Bash') {
805 recentBashCommands.push(e.command)
806 if (recentBashCommands.length > RECENT_BASH_LIMIT) recentBashCommands.shift()
807 }
808 return next(e)
809 })
810
811 // A request's ancestors show that the PowerShell tool runs, not what: while exactly one call
812 // runs, that call is the one (spec §7). The tool has no typed arguments, so it is matched by pattern.
813 on('tool.call', { tool: /^PowerShell$/ }, async ($, e, next) => {
814 const { command } = e as { command?: unknown }
815 if (typeof command !== 'string') return next(e)
816 runningPowerShell.push(command)
817 try {
818 return await next(e)
819 } finally {
820 runningPowerShell.splice(runningPowerShell.indexOf(command), 1)
821 }
822 })
823
824 on('session.end', async ($, e, next) => {
825 // After /clear or an in-process resume the process goes on under a new session id and
826 // no session.start follows, so the listener and the env pointing at its dir stay.
827 // The Windows listener leaves no files: it ends with Claude Code.
828 const isProcessEnding = e.reason !== 'clear' && e.reason !== 'resume'
829 if (!isWin && isProcessEnding && sessionDir !== '') await $.process.run(['rm', '-rf', sessionDir])
830 return next(e)
831 })
832
833 on('command.run', { command: 'askpass' }, async $ => {
834 if (current === null) {
835 $.ui.toast('No pending password request')
836 return { text: 'No pending password request.' }
837 }
838 await openPane($)
839 return { text: 'Password prompt opened.' }
840 })
841
842 // Esc or the close mark: answer empty so the helper exits and sudo gives up.
843 on('ui.close', async ($, e, next) => {
844 const request = current
845 const closed = await next(e)
846 if (e.id === PANE && e.origin.kind === 'person' && request !== null) await submit($, request, '')
847 return closed
848 })
849
850 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
851 const request = current
852 if (!isWaitingForOpen || request === null || e.props.hasSurvey) return next(e)
853 const { Box, Text, Button } = $.ui.resolve(e)
854 const howToOpen = isMainScreen(e)
855 ? 'press ctrl+x tab then Enter, or type /askpass'
856 : 'click the button below or type /askpass'
857 // The hint has a line of its own: beside the button it wraps mid-sentence.
858 return (
859 <Box flexDirection="column">
860 <Text>
861 {askerName(request.requester)} is waiting for a password — {howToOpen}
862 </Text>
863 <Box>
864 {/* ctrl+x tab hands the band the keys; the ring starts here so Enter presses it. */}
865 <Button key="open-askpass" variant="primary" autoFocus onPress={() => openFromBand($)}>
866 Open password prompt
867 </Button>
868 </Box>
869 </Box>
870 )
871 })
872
873 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
874 const elements = $.ui.resolve(e)
875 const { Box, Text, Button } = elements
876 const request = current
877 if (request === null) return <Text dimColor>No pending password request.</Text>
878 // Mobile has no Input; its table hands back a stand-in that draws nothing.
879 if (e.surface === 'mobile' || !('Input' in elements)) {
880 return <Text>Answer this prompt in the terminal or the desktop app.</Text>
881 }
882 const { Input } = elements
883
884 // A fresh key per request and per load: an Input keeps its old text when a key is reused.
885 const key = `password-${request.id}-${LOAD_ID}`
886 const warning = e.props.isFocused ? null : focusWarning(isMainScreen(e))
887 // Clipped at the body's own width, less the paddingX on each side. Expanded, the pane
888 // keeps its size and its body scrolls.
889 const width = Math.max(1, e.props.bodyColumns - 2)
890 const maxRows = isExpanded ? Infinity : MAX_SECTION_ROWS
891 const claude = claudeLine(request, width, maxRows)
892 const isRemote = request.requester.remote !== undefined
893
894 return (
895 <Box flexDirection="column" paddingX={1}>
896 {warning !== null && (
897 <Text color="red" bold>
898 {warning}
899 </Text>
900 )}
901 <Text bold wrap="wrap">
902 {paneHeading(request.requester)}
903 </Text>
904 <Text dimColor wrap="wrap">
905 {askedByText(request.requester)}
906 </Text>
907 {isUnverified(request.requester) && (
908 <Text color="red" wrap="wrap">
909 {UNVERIFIED_WARNING}
910 </Text>
911 )}
912 {isRemote && <Text dimColor>remote</Text>}
913 <Text> </Text>
914 {claude !== null && (
915 <Text dimColor wrap="wrap">
916 {claude}
917 </Text>
918 )}
919 {isRemote && (
920 <Text dimColor wrap="wrap">
921 {REMOTE_WARNING}
922 </Text>
923 )}
924 <Text dimColor wrap="wrap">
925 {commandLine(request, width, maxRows)}
926 </Text>
927 {isClipped(request, width) && (
928 <Box>
929 <Button
930 key="expand"
931 onPress={() => {
932 isExpanded = !isExpanded
933 $.ui.invalidate('ui.render')
934 }}
935 >
936 {isExpanded ? 'Show less' : 'Show full command'}
937 </Button>
938 </Box>
939 )}
940 <Text> </Text>
941 {request.isRetry && <Text color="red">Sorry, try again.</Text>}
942 <Text wrap="wrap">{promptLabel(request.prompt)}</Text>
943 <Box flexDirection="row" columnGap={2}>
944 <Input
945 key={key}
946 autoFocus
947 value={isMasked ? '•'.repeat(typed.length) : typed}
948 submitLabel="send"
949 onInput={value => {
950 typed = isMasked ? applyMaskedInput(typed, value) : value
951 $.ui.invalidate('ui.render')
952 }}
953 onSubmit={value => {
954 const password = isMasked ? applyMaskedInput(typed, value) : value
955 void submit($, request, password)
956 }}
957 />
958 <Button
959 key="toggle"
960 onPress={() => {
961 isMasked = !isMasked
962 $.ui.invalidate('ui.render')
963 }}
964 >
965 {isMasked ? 'Show' : 'Hide'}
966 </Button>
967 </Box>
968 <Text> </Text>
969 <Box>
970 <Button key="cancel" role="dismiss" onPress={() => void submit($, request, '')}>
971 Cancel
972 </Button>
973 </Box>
974 </Box>
975 )
976 })
977}
978hooks/core.ts 415 lines1// Pure logic for the askpass mod: nothing here touches `$`.
2
3const ANSWERED_TTL_MS = 10 * 60 * 1000
4// Whole command lines are shown; this only stops a pathological argv from flooding the pane.
5const MAX_COMMAND_LENGTH = 2000
6
7/** Who a request says asked: shown to the person, never used to refuse it. */
8export type AskSource =
9 | { kind: 'program'; exe: string }
10 | { kind: 'relay'; host: string; realSsh: string | null }
11 | { kind: 'unverified'; exe: string | null }
12
13export type Requester = {
14 pid: number
15 isSudo: boolean
16 comm: string
17 command: string
18 askedBy: AskSource
19 // Set for a request relayed by our ssh wrapper: `host` from the wrapper's own args, the rest
20 // as the remote side reported it, control characters removed.
21 remote?: { host: string; via: string | null; reported: string | null }
22}
23
24/** The `Asked by:` text for a source. */
25export function askedByLine(source: AskSource): string {
26 if (source.kind === 'program') return source.exe
27 if (source.kind === 'relay') return `cc-askpass ssh wrapper → ${source.host}${source.realSsh === null ? '' : ` (${source.realSsh})`}`
28 return `${source.exe ?? 'unknown program'} (unverified)`
29}
30
31const firstLine = (out: string): string => out.split('\n')[0] ?? ''
32
33/** `args` cut to MAX_COMMAND_LENGTH, an ellipsis marking the cut. */
34const clipCommand = (args: string): string =>
35 args.length > MAX_COMMAND_LENGTH ? `${args.slice(0, MAX_COMMAND_LENGTH)}…` : args
36
37/** Splits streamed text into complete lines; `rest` is the unterminated tail. */
38export function splitLines(rest: string, chunk: string): { lines: string[]; rest: string } {
39 const lines = (rest + chunk).split('\n')
40 return { lines: lines.slice(0, -1), rest: lines.at(-1) ?? '' }
41}
42
43/** The session dir from the serve process's first line, `ready<TAB><dir>`. */
44export function parseReady(line: string): string | null {
45 return /^ready\t(.+)$/.exec(line)?.[1] ?? null
46}
47
48/**
49 * A helper's request line, `<pid><TAB><prompt>`, optionally followed by `<TAB><via><TAB><reported>`:
50 * the host chain and the command a relayed prompt claims. Empty optional fields become null.
51 */
52export function parseRequest(
53 line: string,
54): { helperPid: number; prompt: string; via: string | null; reported: string | null } | null {
55 const [, pid, prompt, via, reported] = /^(\d+)\t([^\t]*)(?:\t([^\t]*)(?:\t(.*))?)?$/.exec(line) ?? []
56 if (pid === undefined || prompt === undefined || Number(pid) <= 0) return null
57 return { helperPid: Number(pid), prompt, via: via || null, reported: reported || null }
58}
59
60/**
61 * The host of a process running our ssh wrapper as `[<interpreter> ]<shimPath> --relay <host>`;
62 * null for any other process or a host with characters outside what ssh hosts use.
63 */
64export function relayHost(args: string, shimPath: string): string | null {
65 const afterInterpreter = args.slice(args.indexOf(' ') + 1)
66 for (const candidate of [args, afterInterpreter]) {
67 if (!candidate.startsWith(`${shimPath} --relay `)) continue
68 const host = candidate.slice(shimPath.length + ' --relay '.length)
69 if (/^[A-Za-z0-9._@:%-]+$/.test(host)) return host
70 }
71 return null
72}
73
74/**
75 * A relayed request's label: the reported host chain, then the host we verified, which always
76 * comes last. The wrapper ends `via` with the hop it relays from, so that hop is not doubled.
77 */
78export function remoteLabel(host: string, via: string | null): string {
79 if (!via || via === host) return host
80 return via.endsWith(` via ${host}`) ? via : `${via} via ${host}`
81}
82
83/** `path` with `dir` first, or null when `dir` is already on it (a reload). */
84export function withShim(path: string | undefined, dir: string, separator: ':' | ';' = ':'): string | null {
85 if (path === undefined || path === '') return dir
86 return path.split(separator).includes(dir) ? null : `${dir}${separator}${path}`
87}
88
89/** `path` without `dir`: the Windows shim taken back off when the listener is gone. */
90export function withoutShim(path: string | undefined, dir: string, separator: ':' | ';'): string | undefined {
91 return path?.split(separator).filter(entry => entry !== dir).join(separator)
92}
93
94/**
95 * The `ssh` function every Git Bash imports from `BASH_FUNC_ssh%%`: it runs our wrapper with
96 * MSYS path conversion off. The path is single-quoted: `$` and backticks are legal in it.
97 */
98export function bashSshFunction(shimExe: string): string {
99 const quoted = `'${shimExe.replaceAll('\\', '/').replaceAll("'", `'\\''`)}'`
100 return `() { MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' ${quoted} "$@"\n}`
101}
102
103/** Whether `text` is a function `bashSshFunction` wrote, for any shim path. */
104export function isBashSshFunction(text: string): boolean {
105 return /^\(\) \{ {2}MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='\*' '.*' "\$@"\n\}$/s.test(text)
106}
107
108/**
109 * 64-bit FNV-1a of `text`'s UTF-16 code units, low byte first, as 16 hex digits: names the
110 * Windows helper's build after its source.
111 */
112export function fnv1a64(text: string): string {
113 let hash = 0xcbf29ce484222325n
114 for (let i = 0; i < text.length; i++) {
115 const unit = text.charCodeAt(i)
116 for (const byte of [unit & 0xff, unit >> 8]) hash = ((hash ^ BigInt(byte)) * 0x100000001b3n) & 0xffffffffffffffffn
117 }
118 return hash.toString(16).padStart(16, '0')
119}
120
121/**
122 * The Windows listener's first line, `ready<TAB><pipe name><TAB><32-digit token><TAB><helper path>`:
123 * the path is the listener's own image as the kernel names it, as it names the clients.
124 */
125export function parseWinReady(line: string): { pipe: string; token: string; exe: string } | null {
126 const [, pipe, token, exe] = /^ready\t(cc-askpass-[^\t\\]+)\t([0-9a-f]{32})\t([^\t\r]+)\r?$/.exec(line) ?? []
127 return pipe === undefined || token === undefined || exe === undefined ? null : { pipe, token, exe }
128}
129
130/** A request as the Windows listener describes it: who connected (pid from the kernel) and what it asked. */
131export type WinRequest = {
132 id: number
133 client: { pid: number; exe: string }
134 parent: { pid: number; exe: string; args: string } | null
135 // The client's ancestors from its parent up to Claude Code, for `Claude ran:`.
136 ancestors: { pid: number; args: string }[]
137 prompt: string
138 relay: { host: string; realSsh: string; remotePid: number; via: string | null; reported: string | null } | null
139}
140
141/** One line of the Windows listener: a request, or `{"gone":<id>}` when its asker went away. */
142export function parseWinLine(line: string): WinRequest | { gone: number } | null {
143 let value: Partial<WinRequest> & { gone?: unknown }
144 try {
145 value = JSON.parse(line)
146 } catch {
147 return null
148 }
149 if (typeof value?.gone === 'number') return { gone: value.gone }
150 const { parent, relay, ancestors } = value ?? {}
151 const isRequest =
152 Number.isInteger(value?.id) &&
153 isNumber(value.client?.pid) &&
154 isString(value.client.exe) &&
155 isString(value.prompt) &&
156 (parent === null || (isNumber(parent?.pid) && isString(parent.exe) && isString(parent.args))) &&
157 (relay === null ||
158 (isString(relay?.host) &&
159 isString(relay.realSsh) &&
160 isNumber(relay.remotePid) &&
161 isStringOrNull(relay.via) &&
162 isStringOrNull(relay.reported))) &&
163 Array.isArray(ancestors) &&
164 ancestors.every(ancestor => isNumber(ancestor?.pid) && isString(ancestor.args))
165 return isRequest ? (value as WinRequest) : null
166}
167
168const isNumber = (value: unknown): value is number => typeof value === 'number'
169const isString = (value: unknown): value is string => typeof value === 'string'
170const isStringOrNull = (value: unknown): value is string | null => value === null || isString(value)
171
172// Windows paths ignore case and take either slash.
173const isSamePath = (a: string, b: string): boolean =>
174 a.replaceAll('/', '\\').toLowerCase() === b.replaceAll('/', '\\').toLowerCase()
175
176/**
177 * Who a Windows request says asked: through our helper (`helperExe`, the path the listener
178 * reported for itself), the program that started it; through our ssh wrapper beside it, its
179 * relay; anything else, the client itself, unverified.
180 */
181export function winAskSource(request: WinRequest, helperExe: string): AskSource {
182 const { client, parent, relay } = request
183 const shimExe = helperExe.replace(/[^\\/]*$/, 'shim\\ssh.exe')
184 // File names may hold bidi and format characters: a path shown to expose a program must not
185 // disguise it. The host comes from the wrapper's arguments, which Claude chose.
186 if (isSamePath(client.exe, helperExe)) {
187 return parent === null ? { kind: 'unverified', exe: null } : { kind: 'program', exe: stripControl(parent.exe) }
188 }
189 if (isSamePath(client.exe, shimExe) && relay !== null) {
190 return { kind: 'relay', host: stripControl(relay.host), realSsh: stripControl(relay.realSsh) }
191 }
192 // An image path the listener could not read arrives empty.
193 return { kind: 'unverified', exe: stripControl(client.exe) || null }
194}
195
196/**
197 * A Windows request's Requester, naming the process `Asked by:` names: the program behind our
198 * helper, or else the client. `remote` is left to the caller, which cleans the relay's text.
199 */
200export function winRequester(request: WinRequest, helperExe: string): Requester {
201 const askedBy = winAskSource(request, helperExe)
202 const { client, parent } = request
203 const asker = askedBy.kind === 'program' && parent !== null ? parent : client
204 const args = stripControl(parent?.args ?? '')
205 return {
206 pid: asker.pid,
207 isSudo: false,
208 comm: (stripControl(asker.exe).split(/[\\/]/).at(-1) ?? '').replace(/\.exe$/i, '') || 'unknown program',
209 command: clipCommand(args),
210 askedBy,
211 }
212}
213
214/**
215 * The command Claude ran that led to a Windows request. A Bash call is on its shell's command
216 * line, matched from the outermost ancestor in; a PowerShell call is on none, so it is named
217 * only when an ancestor runs the PowerShell tool's launcher and exactly one call is running.
218 */
219export function winClaudeCommand(
220 ancestors: WinRequest['ancestors'],
221 recentBash: readonly string[],
222 runningPowerShell: readonly string[],
223): string | null {
224 for (const { args } of ancestors.toReversed()) {
225 const command = matchBashCommand(args, recentBash)
226 if (command !== null) return command
227 }
228 const isPowerShell = ancestors.some(({ args }) => args.includes('$__claudeCodeScript'))
229 return isPowerShell && runningPowerShell.length === 1 ? (runningPowerShell[0] ?? null) : null
230}
231
232/**
233 * Git's sh.exe, beside the outermost ancestor that runs an MSYS program (`…\usr\bin\x.exe`):
234 * it can walk the MSYS parents (bin/msys-parents) that Windows lost when Git Bash forked.
235 */
236export function winMsysShell(ancestors: WinRequest['ancestors']): string | null {
237 for (const { args } of ancestors.toReversed()) {
238 const exe = /^"([^"]+)"|^(\S+)/.exec(args)
239 const path = exe?.[1] ?? exe?.[2] ?? ''
240 if (/\\usr\\bin\\[^\\]+\.exe$/i.test(path)) return path.replace(/[^\\]+\.exe$/i, 'sh.exe')
241 }
242 return null
243}
244
245/**
246 * `text` without C0 and C1 control characters (ESC included) and the invisible format and
247 * bidi characters that reorder or hide text: remote text must not drive the terminal or
248 * disguise itself.
249 */
250export function stripControl(text: string): string {
251 return text.replace(
252 /[\u0000-\u001f\u007f-\u009f\u061c\u180e\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
253 '',
254 )
255}
256
257/** Output of `ps -o ppid=,args= -p <helper pid>`; the args keep the prompt's trailing space. */
258export function parseHelperPs(out: string): { ppid: number; args: string } | null {
259 const [, ppid, args] = /^\s*(\d+)\s+(.*)$/.exec(firstLine(out)) ?? []
260 return ppid === undefined || args === undefined ? null : { ppid: Number(ppid), args }
261}
262
263/**
264 * Whether a helper's args run our askpass script: the path must be the program or its
265 * interpreter's first argument, so a path smuggled into the prompt text does not count.
266 */
267export function isOurHelper(args: string, askpassPath: string): boolean {
268 const afterInterpreter = args.slice(args.indexOf(' ') + 1)
269 return [args, afterInterpreter].some(
270 candidate => candidate === askpassPath || candidate.startsWith(`${askpassPath} `),
271 )
272}
273
274/** Output of `ps -o user=,comm=,args= -p <pid>`; macOS prints the full path in `comm`. */
275export function parseParentPs(out: string, pid: number): Omit<Requester, 'askedBy'> | null {
276 const [, user, commPath, args] = /^\s*(\S+)\s+(\S+)\s+(.*?)\s*$/.exec(firstLine(out)) ?? []
277 if (user === undefined || commPath === undefined || args === undefined) return null
278 const comm = commPath.split('/').at(-1) ?? commPath
279 return { pid, isSudo: user === 'root' && comm === 'sudo', comm, command: clipCommand(args) }
280}
281
282/** A prompt for the line above the field, its trailing colon (and blanks) dropped. */
283export function promptLabel(prompt: string): string {
284 return prompt.replace(/\s*[::]?\s*$/, '')
285}
286
287/**
288 * Masks by default: prompts are localized, so only prompts known not to be secret
289 * (git's untranslated username prompt, ssh's host-key question) are shown in clear.
290 */
291export function shouldMask(isSudo: boolean, prompt: string): boolean {
292 return isSudo || !(/^Username for '/.test(prompt) || /\(yes\/no/.test(prompt))
293}
294
295/**
296 * The real text behind a masked field, from the field's new text. Bullets stand for
297 * characters already typed; other characters are new and replace whatever bullets are
298 * missing around them (a select-all paste replaces everything). With no new characters,
299 * the text got shorter, taken as deleted from the end.
300 */
301export function applyMaskedInput(real: string, shown: string): string {
302 const leading = Math.min(/^•*/.exec(shown)?.[0].length ?? 0, real.length)
303 const rest = shown.slice(leading)
304 const trailing = Math.min(/•*$/.exec(rest)?.[0].length ?? 0, real.length - leading)
305 const inserted = rest.slice(0, rest.length - trailing)
306 if (inserted === '') return real.slice(0, shown.length)
307 return real.slice(0, leading) + inserted + real.slice(real.length - trailing)
308}
309
310/**
311 * Sudo pids (and keys of remote requests) already answered: a second request from one means
312 * the password was wrong.
313 */
314export class AnsweredPids {
315 private readonly answeredAt = new Map<number | string, number>()
316
317 mark(pid: number | string, now: number): void {
318 this.prune(now)
319 this.answeredAt.set(pid, now)
320 }
321
322 has(pid: number | string, now: number): boolean {
323 this.prune(now)
324 return this.answeredAt.has(pid)
325 }
326
327 // Old entries go so a recycled pid is not mistaken for a retry.
328 private prune(now: number): void {
329 for (const [pid, at] of this.answeredAt) {
330 if (now - at > ANSWERED_TTL_MS) this.answeredAt.delete(pid)
331 }
332 }
333}
334
335/** The path of a process's resolved executable (bin/exe-of). */
336export function parseExePath(out: string): string | null {
337 return firstLine(out) || null
338}
339
340export type PsTable = Map<number, { ppid: number; args: string }>
341
342/** Output of `ps -e -ww -o pid=,ppid=,args=`. */
343export function parsePsTable(out: string): PsTable {
344 const table: PsTable = new Map()
345 for (const line of out.split('\n')) {
346 const [, pid, ppid, args] = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line) ?? []
347 if (pid !== undefined && ppid !== undefined && args !== undefined) {
348 table.set(Number(pid), { ppid: Number(ppid), args })
349 }
350 }
351 return table
352}
353
354/** The args of `pid`'s ancestor that Claude Code started: the Bash tool's shell, if any. */
355export function bashShellArgs(table: PsTable, pid: number, claudePid: number): string | null {
356 const seen = new Set<number>()
357 for (let current = table.get(pid); current !== undefined; current = table.get(current.ppid)) {
358 if (current.ppid === claudePid) return current.args
359 if (seen.has(current.ppid)) return null
360 seen.add(current.ppid)
361 }
362 return null
363}
364
365/**
366 * Which of Claude's recent Bash commands that shell runs. The tool passes the command
367 * single-quoted, a quote inside written as '"'"', or, with nothing to quote, as a bare word
368 * after `eval`; the whole word must match, so `ls` does not stand in for `./tools/setup.sh`,
369 * and the longest match wins, so `apt update` does not stand in for `sudo sh -c 'apt update'`.
370 */
371export function matchBashCommand(shellArgs: string, recent: readonly string[]): string | null {
372 const unquoted = `${shellArgs.replaceAll(`'"'"'`, `'`)} `
373 let best: string | null = null
374 for (const command of recent) {
375 const isRun = unquoted.includes(`'${command}'`) || unquoted.includes(`eval ${command} `)
376 if (isRun && command.length > (best?.length ?? 0)) best = command
377 }
378 return best
379}
380
381/** The Claude Code pid a session dir is named after (`<session id>-<pid>`). */
382export function claudePidOf(sessionDir: string): number | null {
383 const pid = /-(\d+)$/.exec(sessionDir)?.[1]
384 return pid === undefined ? null : Number(pid)
385}
386
387/** Rows `text` takes when wrapped at `width` characters (wide characters count as one). */
388export function wrappedLines(text: string, width: number): number {
389 return text.split('\n').reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / width)), 0)
390}
391
392/** `text` cut to fit `maxRows` rows at `width`, an ellipsis marking the cut. */
393export function clipLines(text: string, maxRows: number, width: number): string {
394 const kept: string[] = []
395 let rows = 0
396 for (const line of text.split('\n')) {
397 const need = wrappedLines(line, width)
398 if (rows + need <= maxRows) {
399 kept.push(line)
400 rows += need
401 continue
402 }
403 const room = maxRows - rows
404 if (room > 0) {
405 kept.push(`${line.slice(0, room * width - 1)}…`)
406 } else {
407 // The last kept line is full: put the ellipsis in place of its last character.
408 const last = kept.pop() ?? ''
409 kept.push(last.length > 0 && last.length % width === 0 ? `${last.slice(0, -1)}…` : `${last}…`)
410 }
411 return kept.join('\n')
412 }
413 return kept.join('\n')
414}
415