Skip to content

fix(doctor): show the admin prompt reliably during native repairs - #320

Merged
roodboi merged 9 commits into
nextfrom
wf/doctor-sudo-prompt-b13
Oct 13, 2026
Merged

roodboi merged 9 commits into
nextfrom
wf/doctor-sudo-prompt-b13

Conversation

@roodboi

@roodboi roodboi commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On a real macOS desktop Terminal, the native candidate built from next at 63b614f ran hack-v5 doctor --fix. It showed the hack.local repair confirmation (#316), and the user answered yes. The output then printed sudo: a password is required without ever showing a password prompt. The repair then reported Repair refused (declined): The hack.local repair was not authorized; nothing changed.

Root cause

  • The spinner erased the prompt. handleNativeDoctor starts one clack spinner ("Checking the active native HTTPS root...") and keeps it running through every check, including the --fix repairs. The spinner redraws every 80 ms with ESC[1G ESC[J (go to column 1, erase to the end of the screen). sudo's Password: prompt was erased within one frame. I reproduced this in a pseudo-terminal with a fake sudo on PATH: the transcript shows Password: followed at once by ESC[1G ESC[J and a new spinner frame.
  • Process groups were not the cause. runWithTerminalGroup correctly made the sudo child the terminal's foreground process group: in the reproduction, tpgid == pgid for the sudo child.
  • No sudo -n ran before the prompt. Every sudo -n step in the repair (resolver script, launchctl kickstart, cache flush) runs only after authorize succeeds, and its stderr is captured to files.
  • Where the message came from. In sudo 1.9.17p2 (the version on m3), log_auth_failure prints a password is required for any authentication failure without FLAG_BAD_PASSWORD. That includes a first password read that was interrupted or timed out (AUTH_INTR with ntries == 0). So the user, seeing only a spinner, was left at an invisible prompt until the 120 s run timeout (or an interrupt) cancelled it. sudo then printed that line.
  • Why it said "declined". authorize returned false for any non-zero sudo -v exit, and the repair mapped false to declined.

Fix

  • src/lib/terminal-handoff.ts: pausableSpinner and withTerminalHandoff. A repair claims the terminal at its first prompt. The spinner is erased and stays off through the administrator prompt and every later sudo -n step, then resumes once (also on throw). Doctor uses this for both the hack.local repair and the native CA repair (extracted as repairNativeCaTrustCheck). Repairs were already run one at a time; no doctor step runs at the same time as a repair.
  • src/lib/admin-authorization.ts: one shared sudo -v helper for the hack.local repair/rollback, the native CA repair and native global dns activate/deactivate. It refuses before running sudo when Hack is not the terminal's foreground job. It classifies a failure as timed-out, interrupted or rejected and never uses -n.
  • The hack.local authorize dependency now returns approved | declined | authorization-failed. A new closed refusal, authorization-failed, names the cause and suggests Run "sudo -v" in this terminal first, then rerun: …. Only answering no is declined. The CA repair and DNS activation throw errors that name the cause and include the same hint, instead of "was not completed" or "was denied".
  • docs/guides/native-candidate.md: documents that the spinner pauses, the new refusal reason, and the sudo message.

Validation

  • Focused suites, tests/integration-ownership.test.ts, the native DNS activate/deactivate/host suites, global-command.macos, doctor-command and prerelease-plan: 229 pass, 2 skip, 0 fail.
  • bun run --cwd packages/cli typecheck: clean. bun run --cwd packages/cli check: 0 errors. Its 34 warnings are all in files this PR does not touch; the changed files have no diagnostics.
  • Tests inject runners, terminal-foreground state and spinners. They never run real sudo or osascript, and never touch system files or the keychain. They cover:
    • the spinner is paused before the prompt and resumed after;
    • the prompt runs before every privileged sudo -n step;
    • a failed sudo -v is authorization-failed, not declined;
    • a no at the confirmation never reaches sudo;
    • a non-interactive run declines without prompting or pausing;
    • a background run refuses before sudo.
  • Mutation check: I made 13 targeted changes that each break the fix (skip the handoff in either repair, map a failure to declined, prompt for sudo before confirming, drop the foreground check, pass -n, misclassify a timeout, never resume the spinner, prepare the restart before authorizing, install after a failed prompt). The tests caught all 13.
  • Manual check in a pseudo-terminal with a fake sudo (not real sudo): before the fix, the spinner wiped Password:. After the fix, Password: stays visible and the spinner resumes only after sudo exits.

Remaining gaps

  • Not yet run against real sudo in a desktop Terminal. Rebuild the candidate from this branch and run doctor --fix on m3 to confirm the real prompt (or Touch ID sheet, since pam_tid is enabled there) is visible and the repair completes.
  • Draft only; not merged or released.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

roodboi and others added 8 commits October 12, 2026 18:07
Follow-up to #308. A --project or --target argument is sometimes a
mistyped KEY=value or a value pasted into the wrong position, so its
refusal must never contain the argument or a slug derived from it.

- Move parseProjectSelectorOption to src/cli/project-selector.ts and add
  resolveRegisteredProjectOption. project, branch, config, tui, project
  owner and env/host resolvers share it: an argument with "=" or a
  character outside the selector pattern is refused with fixed text
  before any registry read, and "Unknown project" names the project
  only when it was typed in canonical form.
- runCli parses --project for every command declaring optProject before
  the handler runs (including filters, host ps, remote and gateway).
- status/projects name an unmatched filter only when canonical; native
  Compose selectors use the same parser.
- up/down/restart validate --target first, with a fixed message listing
  auto, local, remote.

Legacy alias spellings (My_App, "my app") still select my-app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`hack projects --json --project Sk_Live_Secret_9z` (and `status --json`)
printed `"filter": "sk-live-secret-9z"`, and the slug was also sent to
hackd's /v1/projects query, which echoes it back. That broke the rule that
a slug derived from a non-canonical argument is never echoed.

- resolveListingFilter splits the filter into the in-process match name
  and a label that may be shown: the name when typed canonically or when
  it selects a registered project (the listing shows that name anyway).
- Otherwise JSON reports "filter": null with "filter_redacted": true, the
  empty-result panel uses fixed text, and the daemon is skipped so the
  slug never leaves the process.
- Tests: --json variants of status/projects/prune in the listing-filter
  test, the registered-alias label, and a fake hackd on a Unix socket that
  proves an unnameable filter never reaches /v1/projects while canonical
  and registered filters still do.
- docs/cli.md documents the --project echo rule and filter_redacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests that spawn the source CLI built the child env from
`{ ...process.env, ... }`, so the contributor's HOME and inherited
HACK_*, DOCKER_* and XDG_* variables reached the child CLI. Some had no
HACK_HOME either and used the real ~/.hack.

Add tests/helpers/private-cli-env.ts: privateCliEnv() gives the child a
0700 HOME inside the fixture, inherits only PATH and TMPDIR, and adds the
isolated Docker client config. Migrate every source-CLI spawn under
tests/ that spread process.env, plus the three harnesses fixed in #313.

Add tests/cli-spawn-env-isolation.test.ts, which scans tests/ and fails
when a file that spawns index.ts spreads process.env without a private
HOME. Exceptions are listed with a reason and an exact count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migrate the CLI spawns the first pass missed: the project-name fixture
env (used by project-name-command and the local-network-hint tests),
dispatch-e2e, the MCP contract server env, and the spawns that passed no
env at all (doctor-browser-network, log-pipe, mcp-entrypoint and the
host-exec-tty Python probes).

Rebuild the guard on the TypeScript syntax tree. It now follows a CLI
file's test-local imports and flags any whole copy of the runner env
(spread, direct assignment, Bun.env, Object.assign, rest destructuring),
a HOME derived from the runner's home, a CLI spawn without env, and an
env literal without HOME. Its limits are documented in the scan module.

privateCliEnv now sets mode 0700 on a HOME that already exists, and
native-project-down keeps the private HOME out of the project checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native `doctor --fix` ran every repair under one live clack spinner. The
spinner redraws its line every 80 ms with "column 1 + erase to end of
screen", so sudo's "Password:" prompt was erased within one frame and the
user never saw it. sudo then waited until the 120 s timeout (or an
interrupt) cancelled its first password read, which sudo reports as
"a password is required", and the hack.local repair mapped that non-zero
`sudo -v` exit to a "declined" refusal.

- Add a pausable doctor progress and a terminal handoff: a repair claims
  the terminal at its first prompt, the spinner stays off through the
  administrator prompt and every later sudo -n step, then resumes once.
- Share one `sudo -v` helper across the hack.local repair, the native CA
  repair and native DNS activate/deactivate. It refuses before running
  sudo when Hack is not the terminal's foreground job, and classifies a
  failure as timed out, interrupted or rejected.
- Report an approved change whose administrator prompt fails as the new
  closed refusal `authorization-failed` (with a `sudo -v` hint) instead of
  `declined`; only answering no is `declined`. The CA repair and DNS
  activation name the cause in their errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
host-exec-lifetime built its CLI env without HOME, so the CLI resolved
the runner's real home. It now uses privateCliEnv with the same PATH,
HACK_HOME and HACK_NO_INTERACTIVE values.

lifecycle-process-delivery ran the client CLI through shell run, which
lays env over the runner's whole process.env. New privateCliRunEnv
returns the private env plus unsetEnvKeys for every other runner key,
so the child sees only the private env. LEAKED_PARENT still reaches the
client and must still be absent from the launched command.

The guard now follows same-file function returns for env and argv,
treats src/lib/shell.ts run/exec and same-file wrappers of them as
spawns, and adds shell-runner-env for a shell CLI spawn without
unsetEnvKeys. Object.keys of the env counts as a key-name use.
Reverting either fix fails the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. The merge applied cleanly (D1 changes only tests/
and tests/helpers/; D2 changes src/, docs/cli.md and its own tests).

Semantic check: D2's new CLI-spawning tests
(project-target-argument-redaction-cli, env-key-argument-redaction-cli)
already pass explicit env objects with HOME, so D1's
cli-spawn-env-isolation guard passes on the combined tree without edits.
Regenerated plugins and docs/reference/cli.md: no changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stack PR #320 on PR #318.

Conflicted files: none. The two branches change disjoint file sets, so
git merged every file automatically. Generated surfaces (plugin bundle,
CLI reference, checked-in renders) were regenerated after the merge and
showed no drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi changed the base branch from next to wf/d2-arg-echo-b12 October 13, 2026 01:36
Base automatically changed from wf/d2-arg-echo-b12 to next October 13, 2026 02:17
Re-parents onto next after the previous stacked PR squashed in; next's tree equals 55e9c1e, which this branch already contains, so the branch tree is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 13, 2026 02:17
@roodboi
roodboi merged commit 8d9dc22 into next Oct 13, 2026
30 of 31 checks passed
@roodboi
roodboi deleted the wf/doctor-sudo-prompt-b13 branch October 13, 2026 02:18
roodboi added a commit that referenced this pull request Oct 14, 2026
…add env secrets threat model (#321)

## Summary

HACK-1127 (unit E4). This PR has two parts.

1. It closes the test gap that #320 left on the `hack global dns activate` and `hack global dns
deactivate` administrator prompt. 2. It adds the env and secrets threat model.

## Final behavior

**DNS authorizer seam (`src/lib/native-domain-dns-authorization.ts`, new)**

- The two `authorize` closures in `handleGlobalDnsActivate` and `handleGlobalDnsDeactivate` are
  now one function, `createNativeDnsAuthorizer({ action, domain, dependencies? })`. Its
  injectable dependencies are `isInteractive`, `confirm` and `requestAdministrator`. The
  production defaults are the same calls as before: `process.stdin.isTTY`, `confirmSafe` with
  `nonInteractive: "decline"`, and `requestAdministratorAuthorization`. The confirmation text is
  the same for both actions.
- `requireNativeAdministratorAuthorization` moved out of `src/commands/global.ts` into the new
  module, and it now accepts an injected `requestAdministrator`. When the prompt fails, it
  throws `NativeDnsAdministratorAuthorizationError`. The error carries `outcome: "authorization-
  failed"` and the closed `failure` class, never sudo output. The message text is unchanged.
- `src/commands/global.ts` loses 48 lines. User-visible behavior is unchanged.

**Handler wiring guard (`tests/global-dns-authorizer-wiring.test.ts`, new)**

The handlers need a real macOS host, Homebrew dnsmasq and sudo, so no test runs them. This guard
reads the TypeScript syntax tree of `src/commands/global.ts` and checks each of
`handleGlobalDnsActivate` and `handleGlobalDnsDeactivate`:

- It calls `createNativeDnsAuthorizer` exactly once, with its own `action` string literal and
  `domain`, and stores the result in a `const`.
- That binding is the `authorize` passed to `createNativeDnsHostDependencies`.
- Its flow (`activateNativeDomainDns` or `deactivateNativeDomainDns`) runs with exactly those
  dependencies.
- The only other authorizer allowed is the decline-only `async () => false` on the deactivate
  verify-only path.

It is a syntax check of one file, not data flow. No production code changed in this rework.

**Threat model (`docs/security/env-secrets-threat-model.md`, new; linked from `docs/env.md`)**

The document covers:

- **Assets:** the project env key, encrypted `project_key` YAML, the `encrypted_file` store and
  its key, the `keychain` store, values from 1Password or another external manager, and
  decrypted values in flight.
- **Trust boundaries:** the host user, containers, agents and MCP, CI, and repository inputs.
- **Threats and current mitigations:** argv, log and process-list leakage; symlinks, TOCTOU and
  file modes; hostile ciphertext; public-artifact and git leakage; agent and MCP exfiltration; a
  compromised dependency. Each mitigation cites exact files and symbols from #288, #302, #308,
  #309, #318 and #319.
- **Ten open gaps:**
  1. crash-safe key rotation
  2. the crypto decision record, which is pending independent review (WU14)
  3. live keychain and 1Password custody receipts
  4. the plaintext `.hack/.internal/compose.env.override.yml`, which is written with `Bun.write`
  (umask mode, follows symlinks)
  5. YAML env writes that follow symlinks
  6. `encrypted_file` key-file hygiene
  7. directory TOCTOU
  8. MCP run, exec and log output
  9. container env visibility
  10. node-registry `security -w <value>` argv in the unsupported surface

The doc makes no code change.

## Validation (local, macOS, Bun 1.4.2)

- New `tests/native-domain-dns-authorization.test.ts`: 8 tests. Each one wires the authorizer
  into the real `activateNativeDomainDns` or `deactivateNativeDomainDns` and into
  `createNativeDnsHostDependencies`, with a temp directory, fake privileged and command runners,
  and one ordered effect log. No real sudo, HOME, keychain, Docker or CLI spawn is used.
  - **Activate and deactivate:** the log starts with `confirm`, then a single `sudo -v`. Every
  `sudo -n` step, receipt write and file effect comes after it. The test checks that those
  effects actually ran, so the assertion is not vacuous. The confirmation texts are asserted
  exactly.
  - **Activate and deactivate, failed prompt** (`rejected` exit 1, `timed-out` exit 124):
    - The flow fails in phase `authorization` with `NativeDnsAdministratorAuthorizationError`,
  `outcome: "authorization-failed"`. The message never says "denied".
    - The log is exactly `confirm`, `sudo -v`.
    - No receipt or dnsmasq file is created. For deactivation, the active receipt and both files
  are unchanged.
  - **Activate and deactivate, decline:** the flow fails with "was denied", and the log is
  exactly `confirm`. It never reaches sudo.
  - **Non-interactive:** nothing is prompted and nothing reaches sudo.
  - **Every failure class** (`not-foreground`, `timed-out`, `interrupted`, `rejected`) throws.
  None returns false.
- The same file has 2 more tests for the production defaults. The administrator prompt stays a
  fake in both, so no test reaches real sudo.
  - **Default interactivity check:** with `process.stdin.isTTY` set to undefined or false, the
  authorizer returns false and never confirms. With it set to true, it confirms. The original
  descriptor is restored afterwards.
  - **Default confirmation:** with `HACK_NO_INTERACTIVE=1`, the real `confirmSafe` declines, and
  the authorizer returns false before sudo. The variable is restored afterwards.
- New `tests/global-dns-authorizer-wiring.test.ts`: 2 tests, one per handler.
- Focused suites, 14 files, 153 pass, 1 skip, 0 fail: `global-dns-authorizer-wiring`, `native-
  domain-dns-authorization`, `native-admin-authorization`, `native-domain-dns-host`, `native-
  domain-dns-activate`, `native-domain-dns-deactivate`, `native-hack-local-route-cli`, `doctor-
  env-key`, `cli-reference-docs`, `tests/integration-ownership.test.ts`, `tests/cli-spawn-env-
  isolation.test.ts`, and the other `native-domain-dns-*` and `native-hack-local-route*` suites.
- `bun run --cwd packages/cli typecheck` and root `bun run typecheck`: exit 0.
- `bun run --cwd packages/cli check`: 0 errors. There are 34 existing warnings, none in changed
  files. Biome is clean on the changed TypeScript files.
- The CLI surface and the rendered guidance are unchanged, so `docs:cli-reference`,
  `generate:agent-plugins` and the fingerprint step were not needed.
- I checked that every file path cited in the threat model exists on this head.

## Negative controls

Each mutation was run against the wiring, authorization, activate, deactivate and admin-
authorization suites, then reverted. All 16 are caught.

| Mutation | Result |
| --- | --- |
| `global.ts` activate: `const authorize = async () => true;` | 1 fail |
| `global.ts` deactivate: authorizer built with `action: "activate"` | 1 fail |
| `global.ts` activate: host dependencies get `authorize: async () => true` | 1 fail |
| `global.ts` deactivate: flow gets `{ ...deps, authorize: async () => true }` | 1 fail |
| `global.ts` activate: authorizer built with a different `domain` | 1 fail |
| Default `isInteractive: () => true` | 1 fail |
| Default `isInteractive: () => false` | 1 fail |
| Default confirm `nonInteractive: "accept"` | 1 fail |
| Default confirm `initialValue: true, nonInteractive: "accept-default"` | 1 fail |
| Default confirm always returns true | 1 fail |
| `requireNativeAdministratorAuthorization` returns false instead of throwing | 3 fail |
| Remove the administrator call from the authorizer | 4 fail |
| Ignore a decline | 2 fail |
| Skip the interactive-terminal check | 1 fail |
| Run sudo before the confirmation | 6 fail |
| Throw a plain "denied" `Error` instead of the typed failure | 3 fail |

## Remaining gaps

- **The handlers are guarded by syntax, not run.** The wiring guard proves which authorizer each
  handler installs, but no test executes the handlers themselves. The default
  `requestAdministrator` (real `sudo -v`) is not exercised by any test. `nonInteractive:
  "accept-default"` with `initialValue: false` behaves the same as `"decline"`, so no test can
  tell them apart.
- **No real macOS run.** I did not run `hack global dns activate` or `deactivate` on a real
  host. A real `sudo -v` prompt, a real dnsmasq restart and a real `/etc/resolver` write are
  still unproven for this change.
- **Hosted CI** has not been checked on head `62a9d124`.
- The threat model's open gaps are documented but not fixed. Gaps 4 to 6 are concrete code
  findings that need follow-up changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant