Repository navigation
fix(cli): guard ssh/node prompts without a TTY, add secrets delete --yes, and test every prompt guard - #466
Merged
Merged
Conversation
…yes, and test every prompt guard Follow-ups to #446 (HACK-1385): - Add one bounded non-TTY spawn test per canPrompt() guard (mcp target selection, session picker and attached-workspace choice, update install confirmation, secrets name/value prompts, agent client picker), each proven by mutation. - Guard the clack prompts in `hack ssh` (connection method, host, follow-up choice, Tailscale start) and `hack node pair walkthrough` (input prompts now gate on canPrompt() instead of stdout alone; the execute-now confirm leaves the session pending without a TTY). - `hack secrets delete <name> --yes` confirms the deletion explicitly; without it a TTY-less run still refuses closed with E_INTERACTIVE_REQUIRED, and the hint now names --yes. - tests/global-command.macos.test.ts keeps a TTY-less case for the decline and accept-default policies and asserts the prompt is never entered. Refs HACK-1410 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups to #446 (HACK-1385) from its verifier.
Root cause
@clack/promptsopens a terminal readline over stdin. On a piped stdin it never settles and spins at full CPU. #446 putcanPrompt()guards in front of the known prompt sites, but three gaps were left:mcpguard with a no-op passed every existing test, whileecho | hack mcp installspun.hack sshandhack node pair walkthroughcalled clack with no guard.hack ssh --directandhack ssh --host <h>were confirmed to spin on a piped stdin (killed at the bound with the prompt rendered). Thenodewalkthrough gated its prompts onisTty(), which checks stdout only, so a terminal stdout with a piped stdin still reachedtext/confirm. Its "Execute remote approval now?" confirm had no gate at all.hack secrets delete <name>could not be scripted. Itsfailpolicy refused every run that could not prompt, and no flag confirmed the deletion.Fix
src/commands/ssh.ts, each site behindcanPrompt():E_INTERACTIVE_REQUIRED, naming--tailscaleand--direct --host <host>.--host.tailscale upwithout an answer.src/commands/node.ts:canPrompt()instead ofisTty().isTty()uses (passinginteractiveto the ssh bootstrap) are not clack prompts and are unchanged.src/commands/secrets.ts:hack secrets delete <name> --yesconfirms the deletion explicitly. Without--yes, a run that cannot prompt still refuses closed withE_INTERACTIVE_REQUIREDbefore the keychain is touched, and the hint now names--yes. A TTY run without--yesstill asks, with default no.src/commands/session.ts:promptAttachedWorkspaceActionis exported, with a doc comment, so its guard can be tested directly. In a non-TTY spawn the picker refuses first, so this guard is unreachable through the CLI.docs/cli.mdlistssecrets delete --yesandssh --hostamong the scriptable forms.docs/reference/cli.mdis regenerated (bun run docs:cli-reference) for the new option and description. The consumer agent guidance does not mention these commands, so the plugin bundle is unchanged.Tests
tests/no-tty-prompt-sites-cli.test.ts(new) has 14 cases. Each spawns a real child withprivateCliEnvand a stdin that is piped and closed at once, and sets noHACK_NO_INTERACTIVE. The child also gets a privateHACK_HOME,HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1andHACK_SESSIONS_MUX=none, plus a SIGKILL bound of 10 s and a 30 s test timeout. Each case asserts no signal, wall time under the bound, no clack◆prompt rendered, and the policy outcome:resolveTargetsmcp printE_INTERACTIVE_REQUIREDnaming--all/--cursor/--claudesession(one registered project)hack session list/attach <name>tests/fixtures/session-attached-prompt-worker.ts--newconfirmInstallupdate --jsonwith a fetch-stub preload (release v999) and a stdout-TTY preload{"ok":false,"error":"Non-interactive shell; re-run with --yes …"}; the stand-in binary is untouchedsecrets getBun.secretsstub log is emptysecrets set <name>--yessecrets delete <name>--yes; stub log empty--yessecrets delete <name> --yes(plusHACK_NO_INTERACTIVE=1)Deleted secret, stub logdelete hack-cli <name>agent init --path <repo>ssh--tailscale/--direct/--hostssh --direct--hostssh --host <h>node pair walkthroughwith a stdout-TTY preloadMissing --sourcenode pair walkthrough --source … --endpoint <local Bun.serve>New fixtures:
tests/fixtures/secrets-store-stub-preload.ts: aBun.secretsstub, so no case reaches the OS keychain, even under mutation.tests/fixtures/release-fetch-stub-preload.ts: answers only the GitHub latest-release URL. Any other fetch throws, so nothing is downloaded.tests/fixtures/stdout-tty-preload.ts: a terminal stdout with a piped stdin. It proves the stdin half ofcanPrompt().tests/global-command.macos.test.tsnow counts calls that reach the mocked clackconfirm. Two TTY-less cases (stdin and stdoutisTTY=false, no opt-out) cover the two policies global commands use:global trustkeychain case is nowtest.eachover--no-interactiveand no-TTY. Both assert the sudo keychain install runs andconfirmis never called.global authorizewithout a TTY skips the sudoers install, although the prompt's default is yes, and never callsconfirm.failpolicy.failwithout a TTY is covered by the secrets cases above and by the existingtests/interactivity.test.tsandtests/env-unset-no-tty-cli.test.ts.Mutation proof
Each guard was mutated alone: the guard was made a no-op, or the original gate was restored where one existed. Then only its test was run. All 17 mutations were caught:
if (false)isTty: canPrompt()→trueprocess.stdout.isTTY === true(the pre-#446 gate)canPrompt()→isTty()(the old gate)--yesignored--yestest fails (exit 1, expected 0)--yesalways onconfirmSafegate →!isExplicitlyNonInteractive()(pre-#446)Evidence
mise x bun@1.4.2),bun install --frozen-lockfile, nobun.lockchange.bun run --cwd packages/cli typecheck: 0 errors (coverstests/).bun run check, which includesprivacy:check: 2/2 tasks successful,privacy-check: ok.ultracite checkon every changed file: clean.cli-spawn-env-isolation,global-command.macos,env-unset-no-tty-cli,interactivity,ssh-command, thesession-*files,node-pair-command,node-workspace-command,node-attach-instructions,cli-per-command-options,cli-reference-docs,update-command,mcp-install,mcp,agent-onboard-commandanddoctor-fix-noninteractive.origin/next..HEADrange: no leaks.echo | hack ssh --directandecho | hack ssh --host example.testwere killed at a 6 s / 30 s bound with the clack prompt rendered. After the fix, both settle in about 1–3 s (CLI cold start) with the policy outcome above.testjob at the exact PR head3a3efd24passed: 8109 pass, 0 fail (8266 tests across 575 files, includingtests/no-tty-prompt-sites-cli.test.ts; run 40057772445). The docker-e2e shards 1–4, the ubuntu Candidate core and Config compiler jobs, runtime models, linux-process-lifetime, runtime-images and secret-scan also passed. The macOS Candidate core and Config compiler jobs were still queued for a runner when this was written.Gaps
node.tsstill passesinteractive: isTty()to the ssh key bootstrap (ensureNodePairSshReady). That path runssshwith an inherited stdin, not a clack prompt, so it is out of scope here.ssh/nodeare unsupported surfaces (AGENTS.md); only their clack prompt sites were guarded.fail-policyconfirmSafesite exists inglobalcommands, soglobal-command.macos.test.tscovers decline and accept-default only.Refs HACK-1410
🤖 Generated with Claude Code