Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ The first run opens your browser for login and provider connection. After that,

Prefer a direct binary? Download the latest build from [GitHub Releases](https://github.com/kontext-security/kontext-cli/releases).

Need more detail while debugging startup? Run `kontext start --verbose` or set `KONTEXT_DEBUG=1` to print redacted diagnostics to stderr.

## Managed Credentials

The CLI creates `.env.kontext` locally on first run:
Expand Down
3 changes: 3 additions & 0 deletions cmd/kontext/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func startCmd() *cobra.Command {
var (
agentName string
templateFile string
verbose bool
)

cmd := &cobra.Command{
Expand All @@ -59,6 +60,7 @@ func startCmd() *cobra.Command {
TemplateFile: templateFile,
IssuerURL: auth.DefaultIssuerURL,
ClientID: auth.DefaultClientID,
Verbose: verbose,
Args: args,
})
if exitErr, ok := err.(*run.AgentExitError); ok {
Expand All @@ -71,6 +73,7 @@ func startCmd() *cobra.Command {

cmd.Flags().StringVar(&agentName, "agent", "claude", "Agent to launch (currently: claude)")
cmd.Flags().StringVar(&templateFile, "env-template", ".env.kontext", "Path to env template file")
cmd.Flags().BoolVar(&verbose, "verbose", false, "Show redacted diagnostic output")
Comment thread
michiosw marked this conversation as resolved.

return cmd
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/kontext/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ func TestLogoutCmdSuccess(t *testing.T) {
}
}

func TestStartCmdHasVerboseFlag(t *testing.T) {
cmd := startCmd()
flag := cmd.Flags().Lookup("verbose")
if flag == nil {
t.Fatal("start command missing --verbose flag")
}
if flag.DefValue != "false" {
t.Fatalf("--verbose default = %q, want false", flag.DefValue)
}
}

func TestLogoutCmdAlreadyLoggedOut(t *testing.T) {
cmd := newLogoutCmd(func() error { return keyring.ErrNotFound })

Expand Down
61 changes: 61 additions & 0 deletions internal/diagnostic/diagnostic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package diagnostic

import (
"fmt"
"io"
"os"
"regexp"
"strings"
)

// EnabledFromEnv reports whether diagnostic output was requested by env.
func EnabledFromEnv() bool {
return os.Getenv("KONTEXT_DEBUG") == "1"
}

// Logger writes human diagnostics only when verbose output is enabled.
type Logger struct {
out io.Writer
enabled bool
}

func New(out io.Writer, enabled bool) Logger {
return Logger{out: out, enabled: enabled}
}

func (l Logger) Enabled() bool {
return l.enabled
}

func (l Logger) Printf(format string, args ...any) {
if !l.enabled || l.out == nil {
return
}
fmt.Fprint(l.out, Redact(fmt.Sprintf(format, args...)))
}

var secretPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._~+/=-]+`),
regexp.MustCompile(`(?i)(access_token|id_token|refresh_token|authorization|cookie)=([^&\s]+)`),
regexp.MustCompile(`(?i)(code|token)=([^&\s]+)`),
Comment thread
michiosw marked this conversation as resolved.
}

var jsonSecretPattern = regexp.MustCompile(`(?i)("(?:access_token|id_token|refresh_token|authorization|cookie|code|token)"\s*:\s*")([^"]+)(")`)

// Redact removes credential-shaped values before diagnostics reach stderr.
func Redact(input string) string {
output := jsonSecretPattern.ReplaceAllString(input, `${1}[REDACTED]${3}`)
for _, pattern := range secretPatterns {
output = pattern.ReplaceAllStringFunc(output, func(match string) string {
if len(match) >= 6 && strings.EqualFold(match[:6], "Bearer") {
return "Bearer [REDACTED]"
}
parts := pattern.FindStringSubmatch(match)
if len(parts) >= 2 {
return parts[1] + "=[REDACTED]"
}
return "[REDACTED]"
})
}
return output
}
55 changes: 55 additions & 0 deletions internal/diagnostic/diagnostic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package diagnostic

import (
"bytes"
"strings"
"testing"
)

func TestEnabledFromEnvRequiresOne(t *testing.T) {
t.Setenv("KONTEXT_DEBUG", "1")
if !EnabledFromEnv() {
t.Fatal("EnabledFromEnv() = false, want true")
}

t.Setenv("KONTEXT_DEBUG", "true")
if EnabledFromEnv() {
t.Fatal("EnabledFromEnv() = true, want false")
}
}

func TestLoggerWritesRedactedDiagnosticsOnlyWhenEnabled(t *testing.T) {
var output bytes.Buffer
logger := New(&output, false)
logger.Printf("Authorization: Bearer secret-token")
if output.String() != "" {
t.Fatalf("disabled logger output = %q, want empty", output.String())
}

logger = New(&output, true)
logger.Printf("Authorization: Bearer secret-token code=secret-code")
got := output.String()
if strings.Contains(got, "secret-token") || strings.Contains(got, "secret-code") {
t.Fatalf("diagnostic output leaked secret: %q", got)
}
if !strings.Contains(got, "Bearer [REDACTED]") || !strings.Contains(got, "code=[REDACTED]") {
t.Fatalf("diagnostic output = %q, want redacted markers", got)
}
}

func TestLoggerRedactsJSONSecrets(t *testing.T) {
var output bytes.Buffer
logger := New(&output, true)

logger.Printf(`{"access_token":"secret-token","code":"secret-code","message":"keep"}`)
got := output.String()
if strings.Contains(got, "secret-token") || strings.Contains(got, "secret-code") {
t.Fatalf("diagnostic output leaked JSON secret: %q", got)
}
if !strings.Contains(got, `"access_token":"[REDACTED]"`) || !strings.Contains(got, `"code":"[REDACTED]"`) {
t.Fatalf("diagnostic output = %q, want JSON redaction markers", got)
}
if !strings.Contains(got, `"message":"keep"`) {
t.Fatalf("diagnostic output = %q, want non-secret fields preserved", got)
}
}
76 changes: 59 additions & 17 deletions internal/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/kontext-security/kontext-cli/internal/auth"
"github.com/kontext-security/kontext-cli/internal/backend"
"github.com/kontext-security/kontext-cli/internal/credential"
"github.com/kontext-security/kontext-cli/internal/diagnostic"
"github.com/kontext-security/kontext-cli/internal/sidecar"
)

Expand All @@ -37,15 +38,20 @@ type Options struct {
TemplateFile string
IssuerURL string
ClientID string
Verbose bool
Args []string
}

// Start is the main entry point for `kontext start`.
func Start(ctx context.Context, opts Options) error {
diagnostics := diagnostic.New(os.Stderr, opts.Verbose || diagnostic.EnabledFromEnv())
diagnostics.Printf("start: agent=%s env_template=%s\n", opts.Agent, opts.TemplateFile)
Comment thread
michiosw marked this conversation as resolved.

agentPath, err := preflightAgent(opts.Agent)
if err != nil {
return err
}
diagnostics.Printf("agent preflight: %s -> %s\n", opts.Agent, agentPath)

// 1. Auth
session, err := ensureSession(ctx, opts.IssuerURL, opts.ClientID)
Expand All @@ -63,7 +69,7 @@ func Start(ctx context.Context, opts Options) error {
}

// 2. Backend client — token source refreshes automatically on expiry
client := backend.NewClient(backend.BaseURL(), newSessionTokenSource(ctx, session))
client := backend.NewClient(backend.BaseURL(), newSessionTokenSource(ctx, session, diagnostics))

// 3. Create session via ConnectRPC
hostname, _ := os.Hostname()
Expand Down Expand Up @@ -114,7 +120,8 @@ func Start(ctx context.Context, opts Options) error {

var templateDoc *credential.TemplateFile
if bootstrapErr != nil {
fmt.Fprintf(os.Stderr, "⚠ Provider sync skipped (%v)\n", bootstrapErr)
diagnostics.Printf("provider sync skipped: %v\n", bootstrapErr)
fmt.Fprintln(os.Stderr, "⚠ Provider sync skipped; using the local env template.")
templateDoc, err = credential.LoadTemplateFile(opts.TemplateFile)
if err != nil {
return fmt.Errorf("load env template: %w", err)
Expand Down Expand Up @@ -177,7 +184,7 @@ func Start(ctx context.Context, opts Options) error {
// so reading session fields is safe without synchronization)
var resolved []credential.Resolved
if len(templateDoc.Entries) > 0 {
resolved, err = resolveCredentials(ctx, session, templateDoc.Entries, credentialClientID)
resolved, err = resolveCredentials(ctx, session, templateDoc.Entries, credentialClientID, diagnostics)
if err != nil {
return err
}
Expand Down Expand Up @@ -266,7 +273,7 @@ func ensureSession(ctx context.Context, issuerURL, clientID string) (*auth.Sessi
}

// resolveCredentials exchanges each template entry for a live credential.
func resolveCredentials(ctx context.Context, session *auth.Session, entries []credential.Entry, credentialClientID string) ([]credential.Resolved, error) {
func resolveCredentials(ctx context.Context, session *auth.Session, entries []credential.Entry, credentialClientID string, diagnostics diagnostic.Logger) ([]credential.Resolved, error) {
fmt.Fprintln(os.Stderr, "\nResolving credentials...")
resolved := make([]credential.Resolved, 0, len(entries))
failures := make(map[string]error)
Expand All @@ -277,7 +284,8 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
fmt.Fprintf(os.Stderr, " %s (%s)... ", entry.EnvVar, entry.Target())
value, err := exchangeCredential(ctx, session, entry, credentialClientID)
if err != nil {
fmt.Fprintf(os.Stderr, "⚠ skipped (%v)\n", err)
diagnostics.Printf("credential %s exchange failed: %v\n", entry.EnvVar, err)
fmt.Fprintf(os.Stderr, "⚠ skipped (%s)\n", credentialFailureSummary(err))
failures[entry.EnvVar] = err
continue
}
Expand All @@ -287,7 +295,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr

connectable := unresolvedConnectableEntries(entryByEnvVar, failures)
if len(connectable) == 0 {
printLaunchWarnings(entryByEnvVar, failures)
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
return resolved, nil
}

Expand All @@ -300,11 +308,12 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
auth.Login,
)
if connectErr != nil {
diagnostics.Printf("hosted connect session failed: %v\n", connectErr)
if !interactive && needsGatewayAccessReauthentication(connectErr) {
fmt.Fprintln(os.Stderr, "⚠ Non-interactive session detected. Re-run `kontext start` in an interactive terminal to authorize hosted connect.")
}
fmt.Fprintf(os.Stderr, "⚠ Could not create hosted connect session (%v)\n", connectErr)
printLaunchWarnings(entryByEnvVar, failures)
fmt.Fprintf(os.Stderr, "⚠ Could not create hosted connect session (%s)\n", connectFailureSummary(connectErr))
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
return resolved, nil
}

Expand All @@ -314,13 +323,14 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr

if !interactive {
fmt.Fprintln(os.Stderr, "⚠ Non-interactive session detected. Open this URL in a browser, then rerun `kontext start`.")
printLaunchWarnings(entryByEnvVar, failures)
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
return resolved, nil
}

fmt.Fprintf(os.Stderr, " Opening browser to connect %s...\n", providerList)
if err := browser.OpenURL(connectURL); err != nil {
fmt.Fprintf(os.Stderr, " ⚠ Could not open browser automatically (%v)\n", err)
diagnostics.Printf("hosted connect browser open failed: %v\n", err)
fmt.Fprintln(os.Stderr, " ⚠ Could not open browser automatically.")
fmt.Fprintln(os.Stderr, " Open the URL above to continue.")
}
fmt.Fprint(os.Stderr, " Press Enter after connecting...")
Expand All @@ -331,6 +341,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
session,
connectable,
credentialClientID,
diagnostics,
)
resolved = append(resolved, retriedResolved...)
for _, entry := range connectable {
Expand All @@ -341,7 +352,7 @@ func resolveCredentials(ctx context.Context, session *auth.Session, entries []cr
delete(failures, entry.EnvVar)
}

printLaunchWarnings(entryByEnvVar, failures)
printLaunchWarnings(entryByEnvVar, failures, diagnostics)
return resolved, nil
}

Expand Down Expand Up @@ -369,6 +380,7 @@ func retryConnectableCredentials(
session *auth.Session,
entries []credential.Entry,
credentialClientID string,
diagnostics diagnostic.Logger,
) ([]credential.Resolved, map[string]error) {
attemptDelays := []time.Duration{0, 3 * time.Second, 7 * time.Second}
pending := make(map[string]credential.Entry, len(entries))
Expand Down Expand Up @@ -396,7 +408,8 @@ func retryConnectableCredentials(
)
value, err := exchangeCredential(ctx, session, entry, credentialClientID)
if err != nil {
fmt.Fprintf(os.Stderr, "⚠ skipped (%v)\n", err)
diagnostics.Printf("credential %s retry failed: %v\n", entry.EnvVar, err)
fmt.Fprintf(os.Stderr, "⚠ skipped (%s)\n", credentialFailureSummary(err))
failures[envVar] = err
continue
}
Expand All @@ -410,7 +423,33 @@ func retryConnectableCredentials(
return resolved, failures
}

func printLaunchWarnings(entryByEnvVar map[string]credential.Entry, failures map[string]error) {
func credentialFailureSummary(err error) string {
var resolutionErr *credentialResolutionError
if errors.As(err, &resolutionErr) {
switch resolutionErr.Reason {
case failureDisconnected:
return "provider needs connection"
case failureNotAttached:
return "provider is not attached"
case failureUnknown:
return "unknown provider"
case failureInvalid:
return "invalid placeholder"
case failureTransient:
return "temporary exchange error"
}
}
return "run with --verbose for details"
}

func connectFailureSummary(err error) string {
if needsGatewayAccessReauthentication(err) {
return "gateway access needs authorization"
}
return "run with --verbose for details"
}

func printLaunchWarnings(entryByEnvVar map[string]credential.Entry, failures map[string]error, diagnostics diagnostic.Logger) {
if len(failures) == 0 {
return
}
Expand Down Expand Up @@ -445,12 +484,14 @@ func printLaunchWarnings(entryByEnvVar map[string]credential.Entry, failures map
)
skipped = append(skipped, entry.Provider)
default:
fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%v)\n", entry.EnvVar, err)
diagnostics.Printf("credential %s skipped: %v\n", entry.EnvVar, err)
fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%s)\n", entry.EnvVar, credentialFailureSummary(err))
}
continue
}

fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%v)\n", entry.EnvVar, err)
diagnostics.Printf("credential %s skipped: %v\n", entry.EnvVar, err)
fmt.Fprintf(os.Stderr, "⚠ %s was skipped (%s)\n", entry.EnvVar, credentialFailureSummary(err))
}

if len(skipped) > 0 {
Expand Down Expand Up @@ -794,7 +835,7 @@ func supportedAgents() []string {

// newSessionTokenSource returns a TokenSource that transparently refreshes
// the OIDC access token when it expires, so long-running sessions keep working.
func newSessionTokenSource(ctx context.Context, session *auth.Session) backend.TokenSource {
func newSessionTokenSource(ctx context.Context, session *auth.Session, diagnostics diagnostic.Logger) backend.TokenSource {
mu := &sync.Mutex{}
return func() (string, error) {
mu.Lock()
Expand All @@ -811,7 +852,8 @@ func newSessionTokenSource(ctx context.Context, session *auth.Session) backend.T

// Persist so other processes (and the next `kontext start`) see the new token
if saveErr := auth.SaveSession(refreshed); saveErr != nil {
fmt.Fprintf(os.Stderr, "⚠ Could not persist refreshed session: %v\n", saveErr)
diagnostics.Printf("persist refreshed session failed: %v\n", saveErr)
fmt.Fprintln(os.Stderr, "⚠ Could not persist refreshed session.")
}

// Update the shared session pointer for subsequent calls
Expand Down
Loading
Loading