fix: CLI --password-stdin + honor handler exit codes#253
Conversation
Passwords given on the command line are visible in process listings, shell history, and audit logs. Add --password-stdin to analyze and query-store so scripts and humans can feed the password through a pipe instead. When --password is used inline, emit a stderr warning pointing at the safer alternatives (stdin, env var, credential store). Helper `PasswordResolver.TryResolve` centralizes: - Mutual exclusion between --password and --password-stdin. - Conflict detection between --stdin (plan XML) and --password-stdin. - Validation that stdin is actually redirected when --password-stdin is set. - Fallback to PLANVIEW_PASSWORD env var. - The inline warning for --password. CredentialCommand already reads from redirected stdin when --password is omitted; kept that behavior and added the inline warning for symmetry. Also fixed a pre-existing bug where `Environment.ExitCode = 1` set by handlers was clobbered by `return await root.InvokeAsync(args)`. Program.cs now returns `code != 0 ? code : Environment.ExitCode` so scripts can tell a validation failure from a successful run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
erikdarlingdata
left a comment
There was a problem hiding this comment.
Review summary
What it does: Adds --password-stdin to analyze and query-store so CLI passwords don't have to appear in ps/tasklist/shell history, routes the resolution through a new PasswordResolver, emits a stderr warning when --password inline is used, and fixes a pre-existing bug in Program.cs where Environment.ExitCode = 1 from a handler was clobbered by InvokeAsync returning 0.
What's good:
- Base branch is
dev. ✓ - Mutual-exclusion (
--password+--password-stdin) and stdin-conflict (--stdin+--password-stdin) checks are both in place and fail loud with exit 1. - Warning for inline
--passwordadded symmetrically toCredentialCommandas well, so the advice is consistent across the three commands that accept passwords. - Exit-code fix is a real bug fix —
planview analyze /nonexistent.sqlplanpreviously exited 0 despite the handler settingExitCode = 1. Worth calling out separately in a release note.
What needs attention (see inline):
PasswordResolver.cs:47— empty/EOF stdin silently becomespassword = ""and succeeds; should fail explicitly since--password-stdinimplies "I'm sending one".PasswordResolver.cs:51—string.IsNullOrEmpty(inlinePassword)changes the fall-through semantics for--password ""; previously empty literal was used as-is, now it falls back to env var. Minor behavior delta.Program.cs:30—code != 0 ? code : Environment.ExitCodetechnically doesn't "honor either signal" as the comment claims; the framework's non-zero code wins over the handler's. Fine today, noting for later.CredentialCommand.cs:39—credential adddoesn't get the explicit--password-stdinflag (only the implicit redirect path); three password-input commands now have three slightly different models.PasswordResolver.cs— no unit tests. Five branches, no coverage intests/. End-to-end table in the PR description is nice but doesn't pin the behavior.
Not applicable this PR: Avalonia/XAML concerns (CLI only), PlanAnalyzer rule sync with PerformanceMonitor Dashboard/Lite (no analyzer changes), text-contrast brushes (no UI).
Comments only — maintainer's call.
Generated by Claude Code
| Console.Error.WriteLine("--password-stdin requires stdin to be redirected (pipe the password into the command)."); | ||
| return false; | ||
| } | ||
| password = Console.In.ReadLine()?.TrimEnd('\r', '\n') ?? ""; |
There was a problem hiding this comment.
Console.In.ReadLine() returns null on EOF and "" on an empty line; both collapse to password = "" here and TryResolve returns true. That empty password flows into the connection string and produces a generic SQL auth failure rather than a clear "no password read from stdin" error.
Repro: planview analyze --password-stdin ... < /dev/null (or piping an empty file) silently attempts to connect with an empty password instead of failing at the CLI boundary.
Consider failing explicitly when the read yields null-or-empty, since --password-stdin means "I'm providing a password via stdin" — a zero-byte read is almost certainly a scripting mistake.
Generated by Claude Code
| return true; | ||
| } | ||
|
|
||
| if (!string.IsNullOrEmpty(inlinePassword)) |
There was a problem hiding this comment.
Subtle behavior change from string.IsNullOrEmpty here: the old code was password ??= env.GetValueOrDefault("PLANVIEW_PASSWORD"), which only fell back to the env var when --password was unset (null). Now --password "" (empty string) falls through to envPassword as well.
Pre-change: --password "" with PLANVIEW_PASSWORD=foo → connects with "".
Post-change: same invocation → connects with foo.
Almost certainly nobody depends on passing an empty literal, but worth being aware it's a behavior delta, not just a refactor.
Generated by Claude Code
| // handler set Environment.ExitCode = 1 to signal a validation error. Honor either | ||
| // signal so scripts can tell success from failure. | ||
| var code = await root.InvokeAsync(args); | ||
| return code != 0 ? code : Environment.ExitCode; |
There was a problem hiding this comment.
The expression code != 0 ? code : Environment.ExitCode is asymmetric: if a handler sets Environment.ExitCode = 2 and InvokeAsync returns non-zero for an unrelated reason (e.g. parser error), the handler's signal is dropped. Today no handler uses a code other than 1 so it's fine, but the comment says "honor either signal" — it actually prefers the framework's signal. Minor; leaving a note in case this grows teeth later.
Generated by Claude Code
| string password; | ||
| if (!string.IsNullOrEmpty(passwordArg)) | ||
| { | ||
| Console.Error.WriteLine( |
There was a problem hiding this comment.
credential add doesn't expose an explicit --password-stdin flag (it just has the implicit "read from stdin if redirected" path below). For symmetry with analyze / query-store, consider routing this through PasswordResolver or adding the matching flag — otherwise the three commands end up with three slightly different password-input models (explicit flag, implicit redirect, or env). Not a blocker; just inconsistency surface area.
Generated by Claude Code
| /// redirected when --password-stdin was requested). The caller is responsible | ||
| /// for setting Environment.ExitCode on failure. | ||
| /// </summary> | ||
| public static bool TryResolve( |
There was a problem hiding this comment.
New file with five distinct decision branches (mutual-exclusion, stdin-claimed, stdin-not-redirected, stdin-success, inline-with-warning, env-fallback) and no unit tests. The table in the PR description was exercised end-to-end against the built planview.exe, but there's nothing in tests/ pinning this behavior. A small PasswordResolverTests class (injecting a TextReader + bool inputRedirected instead of reading Console.In directly) would catch the kinds of regressions this kind of branchy validation tends to collect.
Generated by Claude Code
Summary
CLI passwords passed on the command line (
--password hunter2) are visible in process listings (tasklist /v,ps), shell history, and OS audit logs. This PR adds--password-stdinso the password can be piped in instead, and warns when the inline flag is used.While investigating, noticed a pre-existing bug: handlers that set
Environment.ExitCode = 1on validation failure were exiting 0 becausereturn await root.InvokeAsync(args)clobbered the exit code in the top-level program. Fixed that in the same PR since--password-stdindepends on it (the mutual-exclusion and stdin-not-redirected checks would otherwise be advisory).Changes
src/PlanViewer.Cli/PasswordResolver.cs— shared resolver for the CLI password input chain.AnalyzeCommand,QueryStoreCommand: add--password-stdinoption, route through the resolver.CredentialCommand: add the same inline-password warning for symmetry (existing stdin-when-redirected behavior is already correct).Program.cs: propagateEnvironment.ExitCodethrough the top-levelreturn, so handlers that signal failure actually exit non-zero.Resolver rules
--passwordand--password-stdinare mutually exclusive → exit 1.--password-stdin+--stdin(plan XML) → exit 1 (both claim stdin).--password-stdinwithout stdin redirected → exit 1.--passwordinline → emit stderr warning pointing at safer alternatives.PLANVIEW_PASSWORDenv var (unchanged).Test plan
Exercised against the built
planview.exe:--password X --password-stdin--stdin --password-stdin(piped)--password X file.sqlplan(inline)echo X | planview analyze --password-stdin ...planview analyze /nonexistent.sqlplan(pre-existing path)planview --helpAlso verified the
--password-stdinoption appears in--helpfor bothanalyzeandquery-store.🤖 Generated with Claude Code