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>
roodboi
marked this pull request as ready for review
October 24, 2026 05:48
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…urate gitignore entry, no-TTY Tailscale test) (#479) The six small CLI follow-ups in HACK-1426, from the verifiers of #466, #441, #453, #460 and #462. ## Root cause 1. **Tailscale guard had no test.** The `canPrompt()` guard in front of "Turn on Tailscale?" in `setupTailscale` (`src/commands/ssh.ts`) had no case in `tests/no-tty-prompt-sites-cli.test.ts`. If you changed it to `if (false)`, every ssh case still passed. 2. **Cold cache in the first case.** Each run of that file uses a fresh private HOME. Bun keeps its runtime transpiler cache under HOME (`~/Library/Caches/bun/@t@` on macOS), so the first spawn started cold inside its 10 s kill bound. On a loaded host that first case took 6.3 s and was SIGKILLed. 3. **`hack up` dropped the error code.** For a missing project, `runUpCommand` caught `MissingProjectContextError` and, without `--json`, logged only its message. `down`, `restart`, `ps`, `logs` and `open` let the error reach the CLI's closed-error renderer, which prints `E_PROJECT_NOT_FOUND: ...`. 4. **Indented `.gitignore` entry counted as present.** `ensureGitignoreEntry` (`src/lib/fs.ts`) compared trimmed lines. Git keeps leading whitespace as part of the pattern, so ` .hack.secret.key` does not ignore the key. Hack counted that line as the entry, wrote nothing, then found the key not ignored. It then refused with the "a later rule re-includes the key" message, which names the wrong cause. 5. **ACL test could leave mode 0444.** In `tests/git-info-exclude.test.ts`, the macOS ACL test ran `chmod 0444`, the ACL grant and `access(W_OK)` before the `try` whose `finally` restores mode 0644. If any of those steps failed, the file kept mode 0444. 6. **Paired fixture lost a case.** #462 gave the paired `unsearchable` fixture a `.hack/.internal` directory. After that, nothing covered a `.hack` at mode 000 with no `.hack/.internal`, which is the most common real layout. ## Fix 1. New paired no-TTY case: `ssh --tailscale` runs against a logging `tailscale` stand-in placed first on PATH. The stand-in reports `{"BackendState":"Stopped"}`. The case expects exit 1, the `Run \`tailscale up\`, then re-run \`hack ssh\`.` hint, no prompt, and no `tailscale up` call. 2. `beforeAll` now runs one warm-up child (`index.ts --help`) under the file's private HOME. It has its own named budget: `WARM_UP_KILL_AFTER_MS` = 90 s, and the hook bound is that plus 10 s. A kill fails the file with a clear message. The cases keep the 10 s bound. 3. `runUpCommand` now handles `MissingProjectContextError` itself only under `--json`. Without `--json` it rethrows, so `hack up` prints `ERROR: E_PROJECT_NOT_FOUND: No .hack/ (or legacy .dev/) found. Run: hack init` (console logger), the same as the other lifecycle commands. `docs/cli.md` already promised this. 4. `ensureGitignoreEntry` now reads each line the way git does: it drops a trailing CR and trailing spaces, and keeps leading whitespace. An indented line no longer counts as the entry, so Hack appends the exact entry, git ignores the key, and the secret write goes ahead. The "re- included" refusal now appears only when the exact entry is present and git still does not ignore the key, which is what its message says. Tabs are not trimmed, which matches git's `trim_trailing_spaces`. 5. The ACL test's mode change, ACL grant and `access` probe now run inside the `try`. 6. New `unsearchable-no-internal` fixture (never adopted, no `.hack/.internal`). Its `.hack` at mode 000 is the first case in the paired CLI/MCP loop. The proof-matrix row in `docs/reference/ax-target-result-contract.md` now names this case. ## Tests and mutation evidence For each mutation I applied it, ran the focused tests, and restored the file. - **`if (!canPrompt())` changed to `if (false)` in `setupTailscale`:** the new case fails. The child was SIGKILLed at the 10 s bound with the prompt open. The other 3 ssh cases pass. The stand-in recorded no `up` call. - **`up`'s non-JSON branch reverted:** 2 tests fail, `up -p <empty dir> without --json …` and `up from a working directory without a project …`. The same new loop over `down`, `restart`, `ps`, `logs` and `open` still passes. - **`line.trim() === entryLine` restored in `ensureGitignoreEntry`:** 2 tests fail, the CLI case "an indented entry … is completed by an exact entry instead of being refused as re-included" and the unit case "ensureGitignoreEntry reads each line as git does …". - **Exact comparison, `line === entryLine`:** the unit case fails on the trailing-space and CRLF inputs. - **EACCES/EPERM no longer mapped to `permission` in the adoption marker's `classifyStateRootFailure`:** the paired loop fails on its first case, `unsearchable-no- internal` with `.hack` at 000. It gets `adoption_state_unsafe` / `E_CONFIG_INVALID` where `input_permission_denied` / `E_PERMISSION` is expected. - **Warm-up removed (timing, idle host):** the first case took 1.10 s without the warm-up and 0.65 s with it. The later cases are unchanged. ## Validation - Focused tests: 6 files (`no-tty-prompt-sites-cli`, `missing-project-error-code`, `project-env- key-ignore`, `project-target-inspect`, `git-info-exclude`, `doctor-generated-files`), 121 pass and 0 fail. `project-up-command` also passes. Runs used a private HOME, HACK_HOME and DOCKER_CONFIG, and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, as a non-root user on macOS. - `bun run --cwd packages/cli typecheck`: 0 errors. - Root `bun run check`: `privacy-check: ok`. `@hack/cli:check` shows the 34 warnings that were already there. Biome finds no issues in the changed files. - `bun run docs:cli-reference`: no diff. Only an error line changed, not the CLI surface. No consumer agent guidance changed. - gitleaks 8.30.1 (`gitleaks dir` on the exported `git log -p origin/next..HEAD`): no leaks found. - Full suite: the hosted CI `test` job (all shards) at this PR head. ## Gaps - I did not reproduce item 2 under load. The evidence is that the warm-up moves the cold start out of the first case: on an idle host, 1.10 s became 0.65 s. The reported failure was 6.3 s at load about 60. - Item 5 is a structural move with unchanged assertions. No mutation tells the old and new placement apart without failing the ACL setup itself. - As in #462, no mutation tells the no-`.internal` case apart from the `.hack` 000 case with `.internal`. Both get EACCES from the same state-root `lstat`. The new case is live (it fails first under the classifier mutation) but not independent. - Locally, `tests/native-compose-adoption-search-depth.test.ts` "a pending adoption more than 64 directories above …" fails in its setup with `E_LEGACY_ADOPTION_DEADLINE`. It fails the same way with `origin/next`'s `src/`, so this change did not cause it. Hosted CI decides. - Not run as root. The mode-dependent cases are skipped as root, as before. Refs HACK-1426 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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