Files
keyhunter/.planning/phases/01-foundation/01-05-PLAN.md
salvacybersec 684b67cb73 docs(01-foundation): create phase 1 plan — 5 plans across 3 execution waves
Wave 0: module init + test scaffolding (01-01)
Wave 1: provider registry (01-02) + storage layer (01-03) in parallel
Wave 2: scan engine pipeline (01-04, depends on 01-02)
Wave 3: CLI wiring + integration checkpoint (01-05, depends on all)

Covers all 16 Phase 1 requirements: CORE-01 through CORE-07, STOR-01 through STOR-03,
CLI-01 through CLI-05, PROV-10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:44:09 +03:00

25 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 05 execute 3
01-02
01-03
01-04
cmd/root.go
cmd/scan.go
cmd/providers.go
cmd/config.go
pkg/config/config.go
pkg/output/table.go
false
CLI-01
CLI-02
CLI-03
CLI-04
CLI-05
truths artifacts key_links
`keyhunter scan ./testdata/samples/openai_key.txt` runs the pipeline and prints a finding
`keyhunter providers list` prints a table with at least 3 providers
`keyhunter providers info openai` prints OpenAI provider details
`keyhunter config init` creates ~/.keyhunter.yaml without error
`keyhunter config set workers 16` persists the value to ~/.keyhunter.yaml
`keyhunter --help` shows all top-level commands: scan, providers, config
path provides contains
cmd/root.go Cobra root command with PersistentPreRunE config loading cobra.Command
path provides exports
cmd/scan.go scan command wiring Engine + FileSource + output table
scanCmd
path provides exports
cmd/providers.go providers list/info/stats subcommands using Registry
providersCmd
path provides exports
cmd/config.go config init/set/get subcommands using Viper
configCmd
path provides exports
pkg/config/config.go Config struct with Load() and defaults
Config
Load
path provides exports
pkg/output/table.go lipgloss terminal table for printing Findings
PrintFindings
from to via pattern
cmd/scan.go pkg/engine/engine.go engine.NewEngine(registry).Scan() called in RunE engine.NewEngine
from to via pattern
cmd/scan.go pkg/storage/db.go storage.Open() called, SaveFinding for each result storage.Open
from to via pattern
cmd/root.go github.com/spf13/viper viper.SetConfigFile in PersistentPreRunE viper.SetConfigFile
from to via pattern
cmd/providers.go pkg/providers/registry.go Registry.List(), Registry.Get(), Registry.Stats() called registry.List|registry.Get|registry.Stats
Wire all subsystems together through the Cobra CLI: scan command (engine + storage + output), providers list/info/stats commands, and config init/set/get commands. This is the integration layer — all business logic lives in pkg/, cmd/ only wires.

Purpose: Satisfies all Phase 1 CLI requirements and delivers the first working keyhunter scan command that completes the end-to-end success criteria. Output: cmd/{root,scan,providers,config}.go, pkg/config/config.go, pkg/output/table.go.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/01-foundation/01-RESEARCH.md @.planning/phases/01-foundation/01-02-SUMMARY.md @.planning/phases/01-foundation/01-03-SUMMARY.md @.planning/phases/01-foundation/01-04-SUMMARY.md package engine type ScanConfig struct { Workers int; Verify bool; Unmask bool } func NewEngine(registry *providers.Registry) *Engine func (e *Engine) Scan(ctx context.Context, src sources.Source, cfg ScanConfig) (<-chan Finding, error)

package sources func NewFileSource(path string) *FileSource

type Finding struct { ProviderName string KeyValue string KeyMasked string Confidence string Source string LineNumber int }

package storage func Open(path string) (*DB, error) func (db *DB) SaveFinding(f Finding, encKey []byte) (int64, error) func DeriveKey(passphrase []byte, salt []byte) []byte func NewSalt() ([]byte, error)

package providers func NewRegistry() (*Registry, error) func (r *Registry) List() []Provider func (r *Registry) Get(name string) (Provider, bool) func (r *Registry) Stats() RegistryStats

DBPath: ~/.keyhunter/keyhunter.db ConfigPath: ~/.keyhunter.yaml Workers: runtime.NumCPU() * 8 Passphrase: (prompt if not in env KEYHUNTER_PASSPHRASE — Phase 1: use empty string as dev default)

"database.path" → DBPath "scan.workers" → Workers "encryption.passphrase" → Passphrase (sensitive — warn in help)

Columns: PROVIDER | MASKED KEY | CONFIDENCE | SOURCE | LINE Colors: use lipgloss.NewStyle().Foreground() for confidence: high=green, medium=yellow, low=red

Task 1: Config package, output table, and root command pkg/config/config.go, pkg/output/table.go, cmd/root.go - /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (CLI-01, CLI-02, CLI-03 rows, Standard Stack: cobra v1.10.2 + viper v1.21.0) - /home/salva/Documents/apikey/pkg/engine/finding.go (Finding struct fields for output) Create **pkg/config/config.go**: ```go package config

import ( "os" "path/filepath" "runtime" )

// Config holds all KeyHunter runtime configuration. // Values are populated from ~/.keyhunter.yaml, environment variables, and CLI flags (in that precedence order). type Config struct { DBPath string // path to SQLite database file ConfigPath string // path to config YAML file Workers int // number of scanner worker goroutines Passphrase string // encryption passphrase (sensitive) }

// Load returns a Config with defaults applied. // Callers should override individual fields after Load() using viper-bound values. func Load() Config { home, _ := os.UserHomeDir() return Config{ DBPath: filepath.Join(home, ".keyhunter", "keyhunter.db"), ConfigPath: filepath.Join(home, ".keyhunter.yaml"), Workers: runtime.NumCPU() * 8, Passphrase: "", // Phase 1: empty passphrase; Phase 6+ will prompt } }


Create **pkg/output/table.go**:
```go
package output

import (
    "fmt"
    "os"

    "github.com/charmbracelet/lipgloss"
    "github.com/salvacybersec/keyhunter/pkg/engine"
)

var (
    styleHigh   = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))  // green
    styleMedium = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))  // yellow
    styleLow    = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))  // red
    styleHeader = lipgloss.NewStyle().Bold(true).Underline(true)
)

// PrintFindings writes findings as a colored terminal table to stdout.
// If unmask is true, KeyValue is shown; otherwise KeyMasked is shown.
func PrintFindings(findings []engine.Finding, unmask bool) {
    if len(findings) == 0 {
        fmt.Println("No API keys found.")
        return
    }

    // Header
    fmt.Fprintf(os.Stdout, "%-20s  %-40s  %-10s  %-30s  %s\n",
        styleHeader.Render("PROVIDER"),
        styleHeader.Render("KEY"),
        styleHeader.Render("CONFIDENCE"),
        styleHeader.Render("SOURCE"),
        styleHeader.Render("LINE"),
    )
    fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(
        "──────────────────────────────────────────────────────────────────────────────────────────────────────────",
    ))

    for _, f := range findings {
        keyDisplay := f.KeyMasked
        if unmask {
            keyDisplay = f.KeyValue
        }

        confStyle := styleLow
        switch f.Confidence {
        case "high":
            confStyle = styleHigh
        case "medium":
            confStyle = styleMedium
        }

        fmt.Fprintf(os.Stdout, "%-20s  %-40s  %-10s  %-30s  %d\n",
            f.ProviderName,
            keyDisplay,
            confStyle.Render(f.Confidence),
            truncate(f.Source, 28),
            f.LineNumber,
        )
    }
    fmt.Printf("\n%d key(s) found.\n", len(findings))
}

func truncate(s string, max int) string {
    if len(s) <= max {
        return s
    }
    return "..." + s[len(s)-max+3:]
}

Create cmd/root.go (replaces the stub from Plan 01):

package cmd

import (
    "fmt"
    "os"
    "path/filepath"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

// rootCmd is the base command when called without any subcommands.
var rootCmd = &cobra.Command{
    Use:   "keyhunter",
    Short: "KeyHunter — detect leaked LLM API keys across 108+ providers",
    Long: `KeyHunter scans files, git history, and internet sources for leaked LLM API keys.
Supports 108+ providers with Aho-Corasick pre-filtering and regex + entropy detection.`,
    SilenceUsage: true,
}

// Execute is the entry point called by main.go.
func Execute() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.keyhunter.yaml)")
    rootCmd.AddCommand(scanCmd)
    rootCmd.AddCommand(providersCmd)
    rootCmd.AddCommand(configCmd)
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, err := os.UserHomeDir()
        if err != nil {
            fmt.Fprintln(os.Stderr, "warning: cannot determine home directory:", err)
            return
        }
        viper.SetConfigName(".keyhunter")
        viper.SetConfigType("yaml")
        viper.AddConfigPath(home)
        viper.AddConfigPath(".")
    }

    viper.SetEnvPrefix("KEYHUNTER")
    viper.AutomaticEnv()

    // Defaults
    viper.SetDefault("scan.workers", 0) // 0 = auto (CPU*8)
    viper.SetDefault("database.path", filepath.Join(mustHomeDir(), ".keyhunter", "keyhunter.db"))

    // Config file is optional — ignore if not found
    _ = viper.ReadInConfig()
}

func mustHomeDir() string {
    h, _ := os.UserHomeDir()
    return h
}
cd /home/salva/Documents/apikey && go build ./... && ./keyhunter --help 2>&1 | grep -E "scan|providers|config" && echo "HELP OK" - `go build ./...` exits 0 - `./keyhunter --help` shows "scan", "providers", and "config" in command list - pkg/config/config.go exports Config and Load - pkg/output/table.go exports PrintFindings - cmd/root.go declares rootCmd, Execute(), scanCmd, providersCmd, configCmd referenced - `grep -q 'viper\.SetConfigFile\|viper\.SetConfigName' cmd/root.go` exits 0 - lipgloss used for header and confidence coloring Root command, config package, and output table exist. `keyhunter --help` shows the three top-level commands. Task 2: scan, providers, and config subcommands cmd/scan.go, cmd/providers.go, cmd/config.go - /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (CLI-04, CLI-05 rows, Pattern 2 pipeline usage) - /home/salva/Documents/apikey/cmd/root.go (rootCmd, viper setup) - /home/salva/Documents/apikey/pkg/engine/engine.go (Engine.Scan, ScanConfig) - /home/salva/Documents/apikey/pkg/storage/db.go (Open, SaveFinding) - /home/salva/Documents/apikey/pkg/providers/registry.go (NewRegistry, List, Get, Stats) Create **cmd/scan.go**: ```go package cmd

import ( "context" "fmt" "os" "path/filepath" "runtime"

"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/salvacybersec/keyhunter/pkg/config"
"github.com/salvacybersec/keyhunter/pkg/engine"
"github.com/salvacybersec/keyhunter/pkg/engine/sources"
"github.com/salvacybersec/keyhunter/pkg/output"
"github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/storage"

)

var ( flagWorkers int flagVerify bool flagUnmask bool flagOutput string flagExclude []string )

var scanCmd = &cobra.Command{ Use: "scan ", Short: "Scan a file or directory for leaked API keys", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := args[0]

    // Load config
    cfg := config.Load()
    if viper.GetInt("scan.workers") > 0 {
        cfg.Workers = viper.GetInt("scan.workers")
    }

    // Workers flag overrides config
    workers := flagWorkers
    if workers <= 0 {
        workers = cfg.Workers
    }
    if workers <= 0 {
        workers = runtime.NumCPU() * 8
    }

    // Initialize registry
    reg, err := providers.NewRegistry()
    if err != nil {
        return fmt.Errorf("loading providers: %w", err)
    }

    // Initialize engine
    eng := engine.NewEngine(reg)
    src := sources.NewFileSource(target)

    scanCfg := engine.ScanConfig{
        Workers: workers,
        Verify:  flagVerify,
        Unmask:  flagUnmask,
    }

    // Open database (ensure directory exists)
    dbPath := viper.GetString("database.path")
    if dbPath == "" {
        dbPath = cfg.DBPath
    }
    if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
        return fmt.Errorf("creating database directory: %w", err)
    }
    db, err := storage.Open(dbPath)
    if err != nil {
        return fmt.Errorf("opening database: %w", err)
    }
    defer db.Close()

    // Derive encryption key (Phase 1: empty passphrase with fixed dev salt)
    salt := []byte("keyhunter-dev-s0") // Phase 1 placeholder — Phase 6 replaces with proper salt storage
    encKey := storage.DeriveKey([]byte(cfg.Passphrase), salt)

    // Run scan
    ch, err := eng.Scan(context.Background(), src, scanCfg)
    if err != nil {
        return fmt.Errorf("starting scan: %w", err)
    }

    var findings []engine.Finding
    for f := range ch {
        findings = append(findings, f)
        // Persist to storage
        storeFinding := storage.Finding{
            ProviderName: f.ProviderName,
            KeyValue:     f.KeyValue,
            KeyMasked:    f.KeyMasked,
            Confidence:   f.Confidence,
            SourcePath:   f.Source,
            SourceType:   f.SourceType,
            LineNumber:   f.LineNumber,
        }
        if _, err := db.SaveFinding(storeFinding, encKey); err != nil {
            fmt.Fprintf(os.Stderr, "warning: failed to save finding: %v\n", err)
        }
    }

    // Output
    switch flagOutput {
    case "json":
        // Phase 6 — basic JSON for now
        fmt.Printf("[]  # JSON output: Phase 6\n")
    default:
        output.PrintFindings(findings, flagUnmask)
    }

    // Exit code semantics (CLI-05 / OUT-06): 0=clean, 1=found, 2=error
    if len(findings) > 0 {
        os.Exit(1)
    }
    return nil
},

}

func init() { scanCmd.Flags().IntVar(&flagWorkers, "workers", 0, "number of worker goroutines (default: CPU*8)") scanCmd.Flags().BoolVar(&flagVerify, "verify", false, "actively verify found keys (opt-in, Phase 5)") scanCmd.Flags().BoolVar(&flagUnmask, "unmask", false, "show full key values (default: masked)") scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json (more in Phase 6)") scanCmd.Flags().StringSliceVar(&flagExclude, "exclude", nil, "glob patterns to exclude (e.g. *.min.js)") viper.BindPFlag("scan.workers", scanCmd.Flags().Lookup("workers")) }


Create **cmd/providers.go**:
```go
package cmd

import (
    "fmt"
    "os"
    "strings"

    "github.com/charmbracelet/lipgloss"
    "github.com/spf13/cobra"
    "github.com/salvacybersec/keyhunter/pkg/providers"
)

var providersCmd = &cobra.Command{
    Use:   "providers",
    Short: "Manage and inspect provider definitions",
}

var providersListCmd = &cobra.Command{
    Use:   "list",
    Short: "List all loaded provider definitions",
    RunE: func(cmd *cobra.Command, args []string) error {
        reg, err := providers.NewRegistry()
        if err != nil {
            return err
        }
        bold := lipgloss.NewStyle().Bold(true)
        fmt.Fprintf(os.Stdout, "%-20s  %-6s  %-8s  %s\n",
            bold.Render("NAME"), bold.Render("TIER"), bold.Render("PATTERNS"), bold.Render("KEYWORDS"))
        fmt.Println(strings.Repeat("─", 70))
        for _, p := range reg.List() {
            fmt.Fprintf(os.Stdout, "%-20s  %-6d  %-8d  %s\n",
                p.Name, p.Tier, len(p.Patterns), strings.Join(p.Keywords, ", "))
        }
        stats := reg.Stats()
        fmt.Printf("\nTotal: %d providers\n", stats.Total)
        return nil
    },
}

var providersInfoCmd = &cobra.Command{
    Use:   "info <name>",
    Short: "Show detailed info for a provider",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        reg, err := providers.NewRegistry()
        if err != nil {
            return err
        }
        p, ok := reg.Get(args[0])
        if !ok {
            return fmt.Errorf("provider %q not found", args[0])
        }
        fmt.Printf("Name:          %s\n", p.Name)
        fmt.Printf("Display Name:  %s\n", p.DisplayName)
        fmt.Printf("Tier:          %d\n", p.Tier)
        fmt.Printf("Last Verified: %s\n", p.LastVerified)
        fmt.Printf("Keywords:      %s\n", strings.Join(p.Keywords, ", "))
        fmt.Printf("Patterns:      %d\n", len(p.Patterns))
        for i, pat := range p.Patterns {
            fmt.Printf("  [%d] regex=%s confidence=%s entropy_min=%.1f\n",
                i+1, pat.Regex, pat.Confidence, pat.EntropyMin)
        }
        if p.Verify.URL != "" {
            fmt.Printf("Verify URL:    %s %s\n", p.Verify.Method, p.Verify.URL)
        }
        return nil
    },
}

var providersStatsCmd = &cobra.Command{
    Use:   "stats",
    Short: "Show provider statistics",
    RunE: func(cmd *cobra.Command, args []string) error {
        reg, err := providers.NewRegistry()
        if err != nil {
            return err
        }
        stats := reg.Stats()
        fmt.Printf("Total providers: %d\n", stats.Total)
        fmt.Printf("By tier:\n")
        for tier := 1; tier <= 9; tier++ {
            if count := stats.ByTier[tier]; count > 0 {
                fmt.Printf("  Tier %d: %d\n", tier, count)
            }
        }
        fmt.Printf("By confidence:\n")
        for conf, count := range stats.ByConfidence {
            fmt.Printf("  %s: %d\n", conf, count)
        }
        return nil
    },
}

func init() {
    providersCmd.AddCommand(providersListCmd)
    providersCmd.AddCommand(providersInfoCmd)
    providersCmd.AddCommand(providersStatsCmd)
}

Create cmd/config.go:

package cmd

import (
    "fmt"
    "os"
    "path/filepath"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var configCmd = &cobra.Command{
    Use:   "config",
    Short: "Manage KeyHunter configuration",
}

var configInitCmd = &cobra.Command{
    Use:   "init",
    Short: "Create default configuration file at ~/.keyhunter.yaml",
    RunE: func(cmd *cobra.Command, args []string) error {
        home, err := os.UserHomeDir()
        if err != nil {
            return fmt.Errorf("cannot determine home directory: %w", err)
        }
        configPath := filepath.Join(home, ".keyhunter.yaml")

        // Set defaults before writing
        viper.SetDefault("scan.workers", 0)
        viper.SetDefault("database.path", filepath.Join(home, ".keyhunter", "keyhunter.db"))

        if err := viper.WriteConfigAs(configPath); err != nil {
            return fmt.Errorf("writing config: %w", err)
        }
        fmt.Printf("Config initialized: %s\n", configPath)
        return nil
    },
}

var configSetCmd = &cobra.Command{
    Use:   "set <key> <value>",
    Short: "Set a configuration value",
    Args:  cobra.ExactArgs(2),
    RunE: func(cmd *cobra.Command, args []string) error {
        key, value := args[0], args[1]
        viper.Set(key, value)
        if err := viper.WriteConfig(); err != nil {
            // If config file doesn't exist yet, create it
            home, _ := os.UserHomeDir()
            configPath := filepath.Join(home, ".keyhunter.yaml")
            if err2 := viper.WriteConfigAs(configPath); err2 != nil {
                return fmt.Errorf("writing config: %w", err2)
            }
        }
        fmt.Printf("Set %s = %s\n", key, value)
        return nil
    },
}

var configGetCmd = &cobra.Command{
    Use:   "get <key>",
    Short: "Get a configuration value",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        val := viper.Get(args[0])
        if val == nil {
            return fmt.Errorf("key %q not found", args[0])
        }
        fmt.Printf("%v\n", val)
        return nil
    },
}

func init() {
    configCmd.AddCommand(configInitCmd)
    configCmd.AddCommand(configSetCmd)
    configCmd.AddCommand(configGetCmd)
}
cd /home/salva/Documents/apikey && go build -o keyhunter . && ./keyhunter providers list && ./keyhunter providers info openai && echo "PROVIDERS OK" - `go build -o keyhunter .` exits 0 - `./keyhunter --help` shows scan, providers, config commands - `./keyhunter providers list` prints table with >= 3 rows including "openai" - `./keyhunter providers info openai` prints Name, Tier, Keywords, Patterns, Verify URL - `./keyhunter providers stats` prints "Total providers: 3" or more - `./keyhunter config init` creates or updates ~/.keyhunter.yaml - `./keyhunter config set scan.workers 16` exits 0 - `./keyhunter scan testdata/samples/openai_key.txt` exits 1 (keys found) and prints a table row with "openai" - `./keyhunter scan testdata/samples/no_keys.txt` exits 0 and prints "No API keys found." - `grep -q 'viper\.BindPFlag' cmd/scan.go` exits 0 Full CLI works: scan finds and persists keys, providers list/info/stats work, config init/set/get work. Phase 1 success criteria all met. Complete Phase 1 implementation: - Provider registry with 3 YAML definitions, Aho-Corasick automaton, schema validation - Storage layer with AES-256-GCM encryption, Argon2id key derivation, SQLite WAL mode - Three-stage scan engine: keyword pre-filter → regex + entropy detector → finding channel - CLI: keyhunter scan, providers list/info/stats, config init/set/get Run these commands from the project root and confirm each expected output:
  1. cd /home/salva/Documents/apikey && go test ./... -v -count=1 Expected: All tests PASS, zero FAIL, zero SKIP (except original stubs now filled)

  2. ./keyhunter scan testdata/samples/openai_key.txt Expected: Exit code 1, table printed with 1 row showing "openai" provider, masked key

  3. ./keyhunter scan testdata/samples/no_keys.txt Expected: Exit code 0, "No API keys found." printed

  4. ./keyhunter providers list Expected: Table with openai, anthropic, huggingface rows

  5. ./keyhunter providers info openai Expected: Name, Tier 1, Keywords including "sk-proj-", Pattern regex shown

  6. ./keyhunter config init Expected: "Config initialized: ~/.keyhunter.yaml" and the file exists

  7. ./keyhunter config set scan.workers 16 && ./keyhunter config get scan.workers Expected: "Set scan.workers = 16" then "16"

  8. Build the binary with production flags: CGO_ENABLED=0 go build -ldflags="-s -w" -o keyhunter-prod . Expected: Builds without error, binary produced Type "approved" if all 8 checks pass, or describe which check failed and what output you saw.

Full Phase 1 integration check: - `go test ./... -count=1` exits 0 - `./keyhunter scan testdata/samples/openai_key.txt` exits 1 with findings table - `./keyhunter scan testdata/samples/no_keys.txt` exits 0 with "No API keys found." - `./keyhunter providers list` shows 3+ providers - `./keyhunter config init` creates ~/.keyhunter.yaml - `CGO_ENABLED=0 go build -ldflags="-s -w" -o keyhunter-prod .` exits 0

<success_criteria>

  • Cobra CLI with scan, providers, config commands (CLI-01)
  • keyhunter config init creates ~/.keyhunter.yaml (CLI-02)
  • keyhunter config set key value persists (CLI-03)
  • keyhunter providers list/info/stats work (CLI-04)
  • scan flags: --workers, --verify, --unmask, --output, --exclude (CLI-05)
  • All Phase 1 success criteria from ROADMAP.md satisfied:
    1. keyhunter scan ./somefile runs three-stage pipeline and returns findings with provider names
    2. Findings persisted to SQLite with AES-256 encrypted key_value
    3. keyhunter config init and config set work
    4. keyhunter providers list/info return provider metadata from YAML
    5. Provider YAML has format_version and last_verified, validated at load time </success_criteria>
After completion, create `.planning/phases/01-foundation/01-05-SUMMARY.md` following the summary template.