feat: add Discovery Transparency & Trust features

- Add AI provider indicator showing local (Ollama) vs cloud (Anthropic/OpenAI) analysis
- Add "What Discovery Does" explanation section before first scan
- Show commands preview before scan so users know what will run
- Add scan details section showing raw command outputs for admins
- Filter sensitive Docker labels (passwords, secrets, tokens) before AI analysis
- Add comprehensive tests for label filtering

This improves sysadmin confidence by making discovery transparent about
what it does, what data it collects, and where that data goes.
This commit is contained in:
rcourtman
2026-02-03 14:59:27 +00:00
parent 8720708e70
commit 88d95f40be
9 changed files with 550 additions and 12 deletions

View File

@@ -7,6 +7,7 @@ import type {
DiscoveryStatus,
TriggerDiscoveryRequest,
UpdateNotesRequest,
DiscoveryInfo,
} from '../types/discovery';
const API_BASE = '/api/discovery';
@@ -164,6 +165,17 @@ export async function getDiscoveryStatus(): Promise<DiscoveryStatus> {
return response.json();
}
/**
* Get discovery info for a resource type (AI provider info, commands that will run)
*/
export async function getDiscoveryInfo(resourceType: ResourceType): Promise<DiscoveryInfo> {
const response = await apiFetch(`${API_BASE}/info/${resourceType}`);
if (!response.ok) {
throw new Error('Failed to get discovery info');
}
return response.json();
}
/**
* Helper to format the last updated time
*/

View File

@@ -2,6 +2,7 @@ import { Component, Show, For, createSignal, createResource, onCleanup, createEf
import type { ResourceType, DiscoveryProgress } from '../../types/discovery';
import {
getDiscovery,
getDiscoveryInfo,
triggerDiscovery,
updateDiscoveryNotes,
formatDiscoveryAge,
@@ -74,6 +75,20 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
const [scanError, setScanError] = createSignal<string | null>(null);
const [scanProgress, setScanProgress] = createSignal<DiscoveryProgress | null>(null);
const [scanSuccess, setScanSuccess] = createSignal(false);
const [showCommandsPreview, setShowCommandsPreview] = createSignal(false);
const [showExplanation, setShowExplanation] = createSignal(true);
// Fetch discovery info (AI provider, commands) - used for pre-scan transparency
const [discoveryInfo] = createResource(
() => props.resourceType,
async (type) => {
try {
return await getDiscoveryInfo(type);
} catch {
return null;
}
}
);
// Fetch discovery data
const [discovery, { refetch, mutate }] = createResource(
@@ -209,6 +224,92 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
return (
<div class="space-y-4">
{/* AI Provider Badge - Always visible when AI is configured */}
<Show when={discoveryInfo()?.ai_provider}>
<div class="flex items-center gap-2">
<Show
when={discoveryInfo()?.ai_provider?.is_local}
fallback={
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
{/* Cloud icon */}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
Analysis: {discoveryInfo()?.ai_provider?.label}
</span>
}
>
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
{/* Server/local icon */}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
Analysis: {discoveryInfo()?.ai_provider?.label}
</span>
</Show>
</div>
</Show>
{/* "What Discovery Does" Explanation - Shown when no discovery yet */}
<Show when={!discovery() && !discovery.loading && showExplanation()}>
<div class="rounded border border-amber-200 bg-amber-50/80 p-3 shadow-sm dark:border-amber-800/50 dark:bg-amber-900/20">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-2.5">
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xs text-amber-800 dark:text-amber-200">
<p class="font-medium mb-1">What Discovery Does</p>
<p class="text-amber-700 dark:text-amber-300">
Discovery runs read-only commands to gather system information (processes, ports, services),
then uses AI to analyze and identify what's running. No data is stored externally - only the analysis result is saved locally.
</p>
</div>
</div>
<button
onClick={() => setShowExplanation(false)}
class="text-amber-500 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex-shrink-0"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
{/* Commands Preview - Expandable before first scan */}
<Show when={!discovery() && !discovery.loading && discoveryInfo()?.commands && discoveryInfo()!.commands!.length > 0}>
<details class="rounded border border-gray-200 bg-white/70 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30" open={showCommandsPreview()}>
<summary
class="p-2.5 text-xs font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 flex items-center gap-2"
onClick={(e) => { e.preventDefault(); setShowCommandsPreview(!showCommandsPreview()); }}
>
<svg class={`w-3.5 h-3.5 transition-transform ${showCommandsPreview() ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Commands that will run ({discoveryInfo()?.commands?.length || 0})
</summary>
<Show when={showCommandsPreview()}>
<div class="px-3 pb-3 space-y-2">
<For each={discoveryInfo()?.commands}>
{(cmd) => (
<div class="text-xs">
<div class="flex items-start gap-2">
<code class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-mono break-all">
{cmd.command}
</code>
</div>
<p class="text-gray-500 dark:text-gray-400 mt-0.5 pl-0.5">{cmd.description}</p>
</div>
)}
</For>
</div>
</Show>
</details>
</Show>
{/* Loading state */}
<Show when={discovery.loading}>
<div class="flex items-center justify-center py-8">
@@ -562,6 +663,42 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</details>
</Show>
{/* Scan Details / Raw Command Outputs (collapsible) */}
<Show when={d().raw_command_output && Object.keys(d().raw_command_output!).length > 0}>
<details class="rounded border border-gray-200 bg-white/70 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<summary class="p-3 text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
Scan Details ({Object.keys(d().raw_command_output!).length} commands)
</summary>
<div class="px-3 pb-3 space-y-3">
<For each={Object.entries(d().raw_command_output!)}>
{([cmdName, output]) => (
<div>
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{cmdName}
</div>
<pre class="text-[10px] bg-gray-100 dark:bg-gray-800 rounded p-2 overflow-x-auto text-gray-600 dark:text-gray-400 max-h-32 overflow-y-auto">
{output || '(no output)'}
</pre>
</div>
)}
</For>
</div>
</details>
</Show>
{/* Commands Run (for non-admin users who can't see full output) */}
<Show when={!d().raw_command_output && d().scan_duration && d().scan_duration > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-1">
Scan Info
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Scan completed in {(d().scan_duration! / 1000).toFixed(1)}s.
Full scan details are available to administrators.
</p>
</div>
</Show>
{/* Web Interface URL */}
<Show when={props.guestId}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">

View File

@@ -141,3 +141,28 @@ export interface UpdateNotesRequest {
export interface UpdateSettingsRequest {
max_discovery_age_days?: number; // Days before rediscovery (default 30)
}
// AI provider information for discovery transparency
export interface AIProviderInfo {
provider: string; // e.g., "anthropic", "openai", "ollama"
model: string; // e.g., "claude-haiku-4-5", "gpt-4o"
is_local: boolean; // true for ollama (local models)
label: string; // Human-readable label, e.g., "Local (Ollama)" or "Cloud (Anthropic)"
}
// Discovery command information
export interface DiscoveryCommand {
name: string; // Human-readable name
command: string; // The actual command
description: string; // What this command discovers
categories: string[]; // Categories this provides info for
timeout?: number; // Timeout in seconds
optional?: boolean; // If true, failure won't stop discovery
}
// Discovery info metadata (AI provider, commands that will run)
export interface DiscoveryInfo {
ai_provider?: AIProviderInfo; // Current AI provider info
commands?: DiscoveryCommand[]; // Commands that will be run
command_categories?: string[]; // Unique categories of commands
}

View File

@@ -11,12 +11,19 @@ import (
"github.com/rs/zerolog/log"
)
// AIConfigProvider provides access to the current AI configuration.
// This allows discovery handlers to show AI provider info without tight coupling.
type AIConfigProvider interface {
GetAIConfig() *config.AIConfig
}
// Note: adminBypassEnabled() is defined in auth.go
// DiscoveryHandlers handles AI-powered infrastructure discovery endpoints.
type DiscoveryHandlers struct {
service *servicediscovery.Service
config *config.Config // For admin status checks
service *servicediscovery.Service
config *config.Config // For admin status checks
aiConfigProvider AIConfigProvider
}
// NewDiscoveryHandlers creates new discovery handlers.
@@ -32,6 +39,59 @@ func (h *DiscoveryHandlers) SetService(service *servicediscovery.Service) {
h.service = service
}
// SetAIConfigProvider sets the AI config provider for showing AI provider info.
func (h *DiscoveryHandlers) SetAIConfigProvider(provider AIConfigProvider) {
h.aiConfigProvider = provider
}
// getAIProviderInfo returns info about the current AI provider for discovery.
func (h *DiscoveryHandlers) getAIProviderInfo() *servicediscovery.AIProviderInfo {
if h.aiConfigProvider == nil {
return nil
}
aiCfg := h.aiConfigProvider.GetAIConfig()
if aiCfg == nil || !aiCfg.Enabled {
return nil
}
// Get the discovery model
model := aiCfg.GetDiscoveryModel()
if model == "" {
return nil
}
// Parse the model to get provider
provider, modelName := config.ParseModelString(model)
// Determine if local
isLocal := provider == config.AIProviderOllama
// Build human-readable label
var label string
switch provider {
case config.AIProviderOllama:
label = "Local (Ollama)"
case config.AIProviderAnthropic:
label = "Cloud (Anthropic)"
case config.AIProviderOpenAI:
label = "Cloud (OpenAI)"
case config.AIProviderDeepSeek:
label = "Cloud (DeepSeek)"
case config.AIProviderGemini:
label = "Cloud (Google Gemini)"
default:
label = "Cloud (" + provider + ")"
}
return &servicediscovery.AIProviderInfo{
Provider: provider,
Model: modelName,
IsLocal: isLocal,
Label: label,
}
}
// writeDiscoveryJSON writes a JSON response.
func writeDiscoveryJSON(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
@@ -492,3 +552,26 @@ func (h *DiscoveryHandlers) HandleListByHost(w http.ResponseWriter, r *http.Requ
"host": hostID,
})
}
// HandleGetInfo handles GET /api/discovery/info/{type}
// Returns metadata about the discovery process: AI provider info and commands that will run.
func (h *DiscoveryHandlers) HandleGetInfo(w http.ResponseWriter, r *http.Request) {
// Parse resource type from path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/info/")
resourceType := servicediscovery.ResourceType(path)
// Get commands for this resource type
commands := servicediscovery.GetCommandsForResource(resourceType)
categories := servicediscovery.GetCommandCategories(resourceType)
// Get AI provider info
aiProvider := h.getAIProviderInfo()
info := servicediscovery.DiscoveryInfo{
AIProvider: aiProvider,
Commands: commands,
CommandCategories: categories,
}
writeDiscoveryJSON(w, info)
}

View File

@@ -1505,6 +1505,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/discovery", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListDiscoveries)))
r.mux.HandleFunc("/api/discovery/status", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetStatus)))
r.mux.HandleFunc("/api/discovery/settings", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, r.discoveryHandlers.HandleUpdateSettings)))
r.mux.HandleFunc("/api/discovery/info/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetInfo)))
r.mux.HandleFunc("/api/discovery/type/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListByType)))
r.mux.HandleFunc("/api/discovery/host/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
// Route based on method and path depth:
@@ -1988,6 +1989,13 @@ func (r *Router) SetDiscoveryService(svc *servicediscovery.Service) {
}
}
// SetDiscoveryAIConfigProvider sets the AI config provider for showing AI provider info in discovery.
func (r *Router) SetDiscoveryAIConfigProvider(provider AIConfigProvider) {
if r.discoveryHandlers != nil {
r.discoveryHandlers.SetAIConfigProvider(provider)
}
}
// wsHubAdapter adapts websocket.Hub to the servicediscovery.WSBroadcaster interface.
type wsHubAdapter struct {
hub *websocket.Hub
@@ -2253,6 +2261,8 @@ func (r *Router) StartPatrol(ctx context.Context) {
r.SetDiscoveryService(discoveryService)
log.Info().Msg("Discovery: Service wired to API handlers")
}
// Wire up AI config provider for showing AI provider info in discovery UI
r.SetDiscoveryAIConfigProvider(aiService)
}
}
}
@@ -2602,7 +2612,9 @@ func (r *Router) wireChatServiceToAI() {
return
}
ctx := context.Background()
// Use default org context for legacy service wiring
// Multi-tenant orgs get their services wired via setupInvestigationOrchestrator
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
chatSvc := r.aiHandler.GetService(ctx)
if chatSvc == nil {
return
@@ -2635,7 +2647,8 @@ func (r *Router) wireAIChatProviders() {
return
}
service := r.aiHandler.GetService(context.Background())
// Use default org context for legacy service wiring
service := r.aiHandler.GetService(context.WithValue(context.Background(), OrgIDContextKey, "default"))
if service == nil {
return
}

View File

@@ -35,12 +35,12 @@ func shellQuote(s string) string {
// DiscoveryCommand represents a command to run during discovery.
type DiscoveryCommand struct {
Name string // Human-readable name
Command string // The command template
Description string // What this discovers
Categories []string // What categories of info this provides
Timeout int // Timeout in seconds (0 = default)
Optional bool // If true, don't fail if command fails
Name string `json:"name"` // Human-readable name
Command string `json:"command"` // The command template
Description string `json:"description"` // What this discovers
Categories []string `json:"categories"` // What categories of info this provides
Timeout int `json:"timeout"` // Timeout in seconds (0 = default)
Optional bool `json:"optional"` // If true, don't fail if command fails
}
// CommandSet represents a set of commands for a resource type.
@@ -524,3 +524,40 @@ func FormatCLIAccess(resourceType ResourceType, vmid, containerName, namespace,
return result
}
// GetCommandCategories returns a unique sorted list of all categories for a resource type.
func GetCommandCategories(resourceType ResourceType) []string {
commands := GetCommandsForResource(resourceType)
categorySet := make(map[string]bool)
for _, cmd := range commands {
for _, cat := range cmd.Categories {
categorySet[cat] = true
}
}
categories := make([]string, 0, len(categorySet))
for cat := range categorySet {
categories = append(categories, cat)
}
// Sort for consistent ordering
for i := 0; i < len(categories)-1; i++ {
for j := i + 1; j < len(categories); j++ {
if categories[i] > categories[j] {
categories[i], categories[j] = categories[j], categories[i]
}
}
}
return categories
}
// GetCommandSummary returns a human-readable list of commands for a resource type.
func GetCommandSummary(resourceType ResourceType) []string {
commands := GetCommandsForResource(resourceType)
summaries := make([]string, 0, len(commands))
for _, cmd := range commands {
summaries = append(summaries, cmd.Command)
}
return summaries
}

View File

@@ -15,6 +15,59 @@ import (
"github.com/rs/zerolog/log"
)
// sensitiveKeyPatterns defines patterns that indicate a label/env key might contain secrets.
// These patterns are case-insensitive and match if any part of the key contains them.
var sensitiveKeyPatterns = []string{
"password", "passwd", "pwd",
"secret",
"key", "apikey", "api_key",
"token",
"credential", "cred",
"auth",
"private",
"cert",
}
// filterSensitiveLabels removes or redacts labels that may contain sensitive values.
// It returns a new map with sensitive values replaced with "[REDACTED]".
// Keys are checked case-insensitively for sensitive patterns.
func filterSensitiveLabels(labels map[string]string) map[string]string {
if labels == nil {
return nil
}
filtered := make(map[string]string, len(labels))
redactedCount := 0
for key, value := range labels {
keyLower := strings.ToLower(key)
isSensitive := false
for _, pattern := range sensitiveKeyPatterns {
if strings.Contains(keyLower, pattern) {
isSensitive = true
break
}
}
if isSensitive {
filtered[key] = "[REDACTED]"
redactedCount++
} else {
filtered[key] = value
}
}
if redactedCount > 0 {
log.Debug().
Int("redacted_count", redactedCount).
Int("total_labels", len(labels)).
Msg("Redacted sensitive labels before AI analysis")
}
return filtered
}
// StateProvider provides access to the current infrastructure state.
type StateProvider interface {
GetState() StateSnapshot
@@ -1223,7 +1276,8 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
if c.Name == req.ResourceID {
metadata["image"] = c.Image
metadata["status"] = c.Status
metadata["labels"] = c.Labels
// Filter sensitive labels before sending to AI
metadata["labels"] = filterSensitiveLabels(c.Labels)
break
}
}
@@ -1272,7 +1326,8 @@ func (s *Service) buildMetadataAnalysisPrompt(c DockerContainer, host DockerHost
}
if len(c.Labels) > 0 {
info["labels"] = c.Labels
// Filter sensitive labels before sending to AI
info["labels"] = filterSensitiveLabels(c.Labels)
}
if len(c.Mounts) > 0 {

View File

@@ -29,6 +29,167 @@ func (errorAnalyzer) AnalyzeForDiscovery(ctx context.Context, prompt string) (st
return "", context.Canceled
}
func TestFilterSensitiveLabels(t *testing.T) {
tests := []struct {
name string
labels map[string]string
wantKeys map[string]string // expected values (use "[REDACTED]" for sensitive ones)
}{
{
name: "nil labels",
labels: nil,
wantKeys: nil,
},
{
name: "empty labels",
labels: map[string]string{},
wantKeys: map[string]string{},
},
{
name: "safe labels only",
labels: map[string]string{
"app": "myapp",
"version": "1.0.0",
"env": "production",
},
wantKeys: map[string]string{
"app": "myapp",
"version": "1.0.0",
"env": "production",
},
},
{
name: "redacts PASSWORD labels",
labels: map[string]string{
"app": "myapp",
"DB_PASSWORD": "super-secret",
"mysql_password": "another-secret",
"PASSWORD_FILE": "/secrets/pass",
},
wantKeys: map[string]string{
"app": "myapp",
"DB_PASSWORD": "[REDACTED]",
"mysql_password": "[REDACTED]",
"PASSWORD_FILE": "[REDACTED]",
},
},
{
name: "redacts SECRET labels",
labels: map[string]string{
"app": "myapp",
"AWS_SECRET_KEY": "secret123",
"client_secret": "xyz",
},
wantKeys: map[string]string{
"app": "myapp",
"AWS_SECRET_KEY": "[REDACTED]",
"client_secret": "[REDACTED]",
},
},
{
name: "redacts TOKEN labels",
labels: map[string]string{
"app": "myapp",
"ACCESS_TOKEN": "tok_123",
"oauth_token": "tok_456",
},
wantKeys: map[string]string{
"app": "myapp",
"ACCESS_TOKEN": "[REDACTED]",
"oauth_token": "[REDACTED]",
},
},
{
name: "redacts API KEY labels",
labels: map[string]string{
"app": "myapp",
"API_KEY": "key123",
"openai_apikey": "sk-123",
"stripe_api_key": "sk_live_123",
},
wantKeys: map[string]string{
"app": "myapp",
"API_KEY": "[REDACTED]",
"openai_apikey": "[REDACTED]",
"stripe_api_key": "[REDACTED]",
},
},
{
name: "redacts CREDENTIAL labels",
labels: map[string]string{
"app": "myapp",
"DB_CREDENTIALS": "user:pass",
"admin_cred": "admin123",
},
wantKeys: map[string]string{
"app": "myapp",
"DB_CREDENTIALS": "[REDACTED]",
"admin_cred": "[REDACTED]",
},
},
{
name: "redacts AUTH labels",
labels: map[string]string{
"app": "myapp",
"auth_code": "abc123",
"BASIC_AUTH": "dXNlcjpwYXNz",
},
wantKeys: map[string]string{
"app": "myapp",
"auth_code": "[REDACTED]",
"BASIC_AUTH": "[REDACTED]",
},
},
{
name: "mixed sensitive and safe labels",
labels: map[string]string{
"app": "myapp",
"version": "2.0",
"maintainer": "team@example.com",
"DB_PASSWORD": "secret",
"API_KEY": "key123",
"prometheus_port": "9090",
},
wantKeys: map[string]string{
"app": "myapp",
"version": "2.0",
"maintainer": "team@example.com",
"DB_PASSWORD": "[REDACTED]",
"API_KEY": "[REDACTED]",
"prometheus_port": "9090",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterSensitiveLabels(tt.labels)
if tt.wantKeys == nil {
if got != nil {
t.Errorf("filterSensitiveLabels() = %v, want nil", got)
}
return
}
if len(got) != len(tt.wantKeys) {
t.Errorf("filterSensitiveLabels() returned %d labels, want %d", len(got), len(tt.wantKeys))
}
for k, wantV := range tt.wantKeys {
gotV, ok := got[k]
if !ok {
t.Errorf("filterSensitiveLabels() missing key %q", k)
continue
}
if gotV != wantV {
t.Errorf("filterSensitiveLabels()[%q] = %q, want %q", k, gotV, wantV)
}
}
})
}
}
type stubStateProvider struct {
state StateSnapshot
}

View File

@@ -191,6 +191,21 @@ type DiscoveryProgress struct {
Error string `json:"error,omitempty"`
}
// AIProviderInfo describes the AI provider being used for discovery analysis.
type AIProviderInfo struct {
Provider string `json:"provider"` // e.g., "anthropic", "openai", "ollama"
Model string `json:"model"` // e.g., "claude-haiku-4-5", "gpt-4o"
IsLocal bool `json:"is_local"` // true for ollama (local models)
Label string `json:"label"` // Human-readable label, e.g., "Local (Ollama)" or "Cloud (Anthropic)"
}
// DiscoveryInfo provides metadata about the discovery system configuration.
type DiscoveryInfo struct {
AIProvider *AIProviderInfo `json:"ai_provider,omitempty"` // Current AI provider info
Commands []DiscoveryCommand `json:"commands,omitempty"` // Commands that will be run
CommandCategories []string `json:"command_categories,omitempty"` // Unique categories of commands
}
// UpdateNotesRequest represents a request to update user notes.
type UpdateNotesRequest struct {
UserNotes string `json:"user_notes"`