diff --git a/cmd/omni/config_commands.go b/cmd/omni/config_commands.go index 828bf21..c54f7d7 100644 --- a/cmd/omni/config_commands.go +++ b/cmd/omni/config_commands.go @@ -48,7 +48,6 @@ func configInitCmd() *cobra.Command { name string endpoint string authMethod string - apiKey string ) cmd := &cobra.Command{ @@ -57,15 +56,17 @@ func configInitCmd() *cobra.Command { Long: `Create a new configuration profile. Prompts interactively for any value not supplied via flags. With --name, ---endpoint, and --auth all set, no prompts are shown.`, +--endpoint, and --auth oauth set, OAuth setup runs with no prompts. For +api-key auth the key is always read from a hidden prompt — it is never +accepted as a flag, so it can't leak into shell history.`, Example: ` # Interactive setup omni config init # Non-interactive OAuth (opens browser for login) omni config init --name prod --endpoint https://myorg.omniapp.co --auth oauth - # Non-interactive API key - omni config init --name prod --endpoint https://myorg.omniapp.co --api-key "$OMNI_API_TOKEN"`, + # API key (prompts securely for the key) + omni config init --name prod --endpoint https://myorg.omniapp.co --auth api-key`, RunE: func(cmd *cobra.Command, args []string) error { reader := bufio.NewReader(os.Stdin) @@ -86,8 +87,6 @@ Prompts interactively for any value not supplied via flags. With --name, choice := strings.TrimSpace(strings.ToLower(authMethod)) switch { - case choice == "" && apiKey != "": - choice = "api-key" case choice == "": fmt.Println("Authentication method:") fmt.Println(" 1) API key") @@ -99,10 +98,6 @@ Prompts interactively for any value not supplied via flags. With --name, return fmt.Errorf("invalid --auth %q — must be %q or %q", authMethod, "api-key", "oauth") } - if choice == "oauth" && apiKey != "" { - return fmt.Errorf("--api-key cannot be combined with --auth oauth") - } - cfg, err := config.Load() if err != nil { cfg = &config.Config{ @@ -126,15 +121,15 @@ Prompts interactively for any value not supplied via flags. With --name, cfg.Profiles[name] = p default: // "1", "a", "api-key", or empty - if apiKey == "" { - fmt.Print("API key: ") - apiKeyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return fmt.Errorf("reading API key: %w", err) - } - apiKey = strings.TrimSpace(string(apiKeyBytes)) + // The key is always read from a hidden prompt rather than a + // flag, so it never lands in shell history or process listings. + fmt.Print("API key: ") + apiKeyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return fmt.Errorf("reading API key: %w", err) } + apiKey := strings.TrimSpace(string(apiKeyBytes)) cfg.Profiles[name] = config.Profile{ APIEndpoint: endpoint, @@ -158,8 +153,7 @@ Prompts interactively for any value not supplied via flags. With --name, cmd.Flags().StringVar(&name, "name", "", "profile name (skips prompt)") cmd.Flags().StringVar(&endpoint, "endpoint", "", "API endpoint, e.g. https://myorg.omniapp.co (skips prompt)") - cmd.Flags().StringVar(&authMethod, "auth", "", `authentication method: "api-key" or "oauth" (skips prompt)`) - cmd.Flags().StringVar(&apiKey, "api-key", "", "API key (skips prompt; implies --auth api-key)") + cmd.Flags().StringVar(&authMethod, "auth", "", `authentication method: "api-key" or "oauth" (skips prompt). The API key itself is always read from a hidden prompt, never a flag.`) return cmd } diff --git a/cmd/omni/config_commands_test.go b/cmd/omni/config_commands_test.go index 3c8da59..4ccbc58 100644 --- a/cmd/omni/config_commands_test.go +++ b/cmd/omni/config_commands_test.go @@ -82,47 +82,12 @@ func TestApplyOAuthToken_CopiesAllFields(t *testing.T) { } // --- config init (non-interactive flags) --- - -// With --name, --endpoint, and --api-key all supplied, init must not prompt -// for anything and should write the profile straight to disk. -func TestConfigInit_NonInteractiveAPIKey(t *testing.T) { - withConfig(t, nil) - - cmd := configInitCmd() - cmd.SetArgs([]string{ - "--name", "prod", - "--endpoint", "https://myorg.omniapp.co", - "--api-key", "sk-test-1234", - }) - out := captureStdout(t, func() { - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - }) - if strings.Contains(out, "Profile name:") || strings.Contains(out, "Choose [1/2]:") { - t.Errorf("expected no interactive prompts, got:\n%s", out) - } - - cfg, err := config.Load() - if err != nil { - t.Fatalf("Load: %v", err) - } - p := cfg.Profiles["prod"] - if p.APIEndpoint != "https://myorg.omniapp.co" { - t.Errorf("APIEndpoint = %q", p.APIEndpoint) - } - // --api-key alone implies --auth api-key. - if p.AuthMethod != "api-key" { - t.Errorf("AuthMethod = %q, want %q", p.AuthMethod, "api-key") - } - if p.APIKey != "sk-test-1234" { - t.Errorf("APIKey = %q", p.APIKey) - } - // First profile becomes the default. - if cfg.DefaultProfile != "prod" { - t.Errorf("DefaultProfile = %q, want %q", cfg.DefaultProfile, "prod") - } -} +// +// Note: there is deliberately no --api-key flag. Accepting a secret on the +// command line leaks it into shell history and process listings (and from +// there into anything that scrapes those, including agentic tooling). The key +// is always read from a hidden prompt instead; only the non-secret --name, +// --endpoint, and --auth values can be supplied non-interactively. // --auth oauth must validate the endpoint BEFORE launching the browser flow, // so a bad endpoint fails fast instead of opening a browser against it. @@ -167,27 +132,6 @@ func TestConfigInit_InvalidAuthFlag(t *testing.T) { } } -func TestConfigInit_APIKeyConflictsWithOAuth(t *testing.T) { - withConfig(t, nil) - - cmd := configInitCmd() - cmd.SilenceUsage = true - cmd.SilenceErrors = true - cmd.SetArgs([]string{ - "--name", "prod", - "--endpoint", "https://myorg.omniapp.co", - "--auth", "oauth", - "--api-key", "sk-test-1234", - }) - err := cmd.Execute() - if err == nil { - t.Fatal("expected conflict error, got nil") - } - if !strings.Contains(err.Error(), "--api-key cannot be combined") { - t.Errorf("error = %q, want api-key/oauth conflict message", err.Error()) - } -} - // --- config logout --- func TestConfigLogout_ClearsTokensForNamedProfile(t *testing.T) {