Repository navigation
fix(doctor): show the admin prompt reliably during native repairs - #320
Merged
Merged
Conversation
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>
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
marked this pull request as ready for review
October 13, 2026 02:17
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)
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.
Problem
On a real macOS desktop Terminal, the native candidate built from
nextat 63b614f ranhack-v5 doctor --fix. It showed the hack.local repair confirmation (#316), and the user answered yes. The output then printedsudo: a password is requiredwithout ever showing a password prompt. The repair then reportedRepair refused (declined): The hack.local repair was not authorized; nothing changed.Root cause
handleNativeDoctorstarts one clack spinner ("Checking the active native HTTPS root...") and keeps it running through every check, including the--fixrepairs. The spinner redraws every 80 ms withESC[1G ESC[J(go to column 1, erase to the end of the screen). sudo'sPassword:prompt was erased within one frame. I reproduced this in a pseudo-terminal with a fakesudoonPATH: the transcript showsPassword:followed at once byESC[1G ESC[Jand a new spinner frame.runWithTerminalGroupcorrectly made the sudo child the terminal's foreground process group: in the reproduction,tpgid == pgidfor the sudo child.sudo -nran before the prompt. Everysudo -nstep in the repair (resolver script, launchctl kickstart, cache flush) runs only afterauthorizesucceeds, and its stderr is captured to files.log_auth_failureprintsa password is requiredfor any authentication failure withoutFLAG_BAD_PASSWORD. That includes a first password read that was interrupted or timed out (AUTH_INTRwithntries == 0). So the user, seeing only a spinner, was left at an invisible prompt until the 120 sruntimeout (or an interrupt) cancelled it. sudo then printed that line.authorizereturnedfalsefor any non-zerosudo -vexit, and the repair mappedfalsetodeclined.Fix
src/lib/terminal-handoff.ts:pausableSpinnerandwithTerminalHandoff. A repair claims the terminal at its first prompt. The spinner is erased and stays off through the administrator prompt and every latersudo -nstep, then resumes once (also on throw). Doctor uses this for both the hack.local repair and the native CA repair (extracted asrepairNativeCaTrustCheck). 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 sharedsudo -vhelper for the hack.local repair/rollback, the native CA repair and nativeglobal dns activate/deactivate. It refuses before running sudo when Hack is not the terminal's foreground job. It classifies a failure astimed-out,interruptedorrejectedand never uses-n.authorizedependency now returnsapproved | declined | authorization-failed. A new closed refusal,authorization-failed, names the cause and suggestsRun "sudo -v" in this terminal first, then rerun: …. Only answering no isdeclined. 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
tests/integration-ownership.test.ts, the native DNS activate/deactivate/host suites,global-command.macos,doctor-commandandprerelease-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.sudo -nstep;sudo -visauthorization-failed, notdeclined;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.sudo(not real sudo): before the fix, the spinner wipedPassword:. After the fix,Password:stays visible and the spinner resumes only after sudo exits.Remaining gaps
doctor --fixon m3 to confirm the real prompt (or Touch ID sheet, sincepam_tidis enabled there) is visible and the repair completes.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.