diff --git a/README.md b/README.md index e37e184..2303f44 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ People juggling multiple Anthropic or OpenAI accounts, relay endpoints, or work- ## Not for -claudecm does **not** sync your configuration to the cloud, is **not** a proxy or gateway (it never sees a request), does **not** support Gemini CLI / Cursor / Windsurf / other IDE plugins in v1, and does **not** encrypt profiles at rest — they are plaintext YAML at file mode `0600` under `~/.claudecm/` (deferred post-v1 per ADR-0001 and PRD NFR-D1). If you need vault-grade secret storage, wire claudecm's `import`/`export` around your existing secret manager instead. +claudecm does **not** sync your configuration to the cloud, is **not** a proxy or gateway for Claude Code or Codex traffic, does **not** support Gemini CLI / Cursor / Windsurf / other IDE plugins in v1, and does **not** encrypt profiles at rest — they are plaintext YAML at file mode `0600` under `~/.claudecm/` (deferred post-v1 per ADR-0001 and PRD NFR-D1). The only networked onboarding path is `add --from-text ... --ai`, which is explicit per run, requires an interactive terminal to review and confirm the desensitized payload, and sends one locally desensitized parse request using your chosen claudecm profile credentials. If you need vault-grade secret storage, wire claudecm's `import`/`export` around your existing secret manager instead. ## Install @@ -67,6 +67,15 @@ claudecm add work \ claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx --dry-run claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx +# Or paste a provider snippet. This path is local by default and does not +# use the network; --dry-run previews the redacted draft without writing. +claudecm add work --from-text 'ANTHROPIC_BASE_URL=https://api.anthropic.com ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxxxxx' --dry-run + +# Optional AI parse is opt-in per invocation and requires an interactive TTY. +# claudecm strips secret-shaped tokens locally, shows the desensitized payload +# for confirmation, then sends one Anthropic-compatible messages request. +claudecm add work --from-text 'messy provider note with sk-ant-xxxxxxxx' --ai --dry-run + # 3. Switch. claudecm switch work --yes # In an interactive terminal, bare `claudecm switch` opens a fuzzy @@ -131,6 +140,16 @@ tools: Presets are convenience templates, not official provider support, certification, endorsement, or compatibility guarantees. Every generated field is overridable with explicit flags or `--set`; provider endpoints and model names can drift, so edit the profile when a provider changes its API. +## Smart add inputs + +`claudecm add` can also build drafts from existing local material: + +- `--from-env` reads the Claude Code / Codex environment-variable allowlist. +- `--from-file ` parses dotenv, shell, JSON, YAML, or TOML config files. +- `--from-text ` or `--from-text -` parses pasted text with local heuristics. + +These paths are local-first. Without `--ai`, pasted text never leaves the machine. `--ai` is an explicit escalation for `--from-text`: claudecm requires an interactive terminal, runs the local redaction pass first, shows the full desensitized payload for confirmation, keeps captured secrets in-process, sends only the confirmed desensitized text to an Anthropic-compatible Messages endpoint using the active profile's credentials (or `--ai-profile `), then re-injects the secret locally before the normal redacted preview/save path. Non-interactive or piped `--ai` runs refuse before any parse request is sent. + ## Deeper reading - [`docs/prd/prd-v1.md`](docs/prd/prd-v1.md) — v1 PRD, functional + non-functional requirements, decision log. diff --git a/cmd/add.go b/cmd/add.go index 61bbe20..a6289cc 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -28,15 +28,19 @@ package cmd import ( + "context" "encoding/json" "fmt" "io" + "os" "sort" "strings" "time" "github.com/spf13/cobra" + "github.com/a2d2-dev/claudecm/internal/aiparse" + "github.com/a2d2-dev/claudecm/internal/blobparse" "github.com/a2d2-dev/claudecm/internal/config" "github.com/a2d2-dev/claudecm/internal/envextract" "github.com/a2d2-dev/claudecm/internal/fileparse" @@ -88,6 +92,9 @@ var ( addPresetFlag string addFromEnvFlag bool addFromFileFlag string + addFromTextFlag string + addAIFlag bool + addAIProfileFlag string addListPresetsFlag bool addDryRunFlag bool addOverwriteFlag bool @@ -111,6 +118,24 @@ func SetNowForTest(fn func() time.Time) func() { return func() { nowFn = prev } } +type addLLMParser interface { + Parse(ctx context.Context, desensitized string, creds aiparse.Credentials) (config.CoreConfig, error) +} + +// newAddLLMParser is the --ai transport seam. Tests replace it with a mock +// parser so cmd/add never performs real network I/O under test. Documented +// exception to coding-standards rule 12, matching nowFn and isTerminalFn. +var newAddLLMParser = func() addLLMParser { + return aiparse.NewClient(nil) +} + +// SetAddLLMParserForTest overrides the --ai parser factory. +func SetAddLLMParserForTest(fn func() addLLMParser) func() { + prev := newAddLLMParser + newAddLLMParser = fn + return func() { newAddLLMParser = prev } +} + var addCmd = &cobra.Command{ Use: "add [profile-name]", Short: "Create a new profile in the unified schema (v1)", @@ -159,6 +184,13 @@ EXAMPLES claudecm add work --preset moonshot --api-key sk-... --dry-run claudecm add work --preset moonshot --api-key sk-... --model kimi-k2-latest + # Start from pasted text locally; use - to read stdin. + claudecm add work --from-text 'ANTHROPIC_BASE_URL=https://api.anthropic.com ANTHROPIC_AUTH_TOKEN=sk-...' --dry-run + cat provider.txt | claudecm add work --from-text - + + # Opt in to one secret-free LLM parse from an interactive terminal. + claudecm add work --from-text 'messy provider note with sk-...' --ai --dry-run + # Discover built-in presets claudecm add --list-presets @@ -189,6 +221,9 @@ func init() { addCmd.Flags().StringVar(&addPresetFlag, "preset", "", "Built-in provider preset name (run --list-presets to discover)") addCmd.Flags().BoolVar(&addFromEnvFlag, "from-env", false, "Build the profile draft from Claude Code / Codex environment variables") addCmd.Flags().StringVar(&addFromFileFlag, "from-file", "", "Build the profile draft from a dotenv, shell, JSON, YAML, or TOML file") + addCmd.Flags().StringVar(&addFromTextFlag, "from-text", "", "Build the profile draft from pasted text locally; use '-' to read stdin") + addCmd.Flags().BoolVar(&addAIFlag, "ai", false, "With --from-text, opt in to one interactive, reviewed, secret-free Anthropic-compatible LLM parse") + addCmd.Flags().StringVar(&addAIProfileFlag, "ai-profile", "", "Profile whose Anthropic-compatible credentials are borrowed for --ai parsing") addCmd.Flags().BoolVar(&addListPresetsFlag, "list-presets", false, "List built-in provider presets and exit") addCmd.Flags().StringArrayVar(&addSetFlag, "set", nil, "Sparse overlay entry (repeatable). Format: tools..=. "+ @@ -236,7 +271,7 @@ func runAdd(cmd *cobra.Command, args []string) error { if err := validateAddInputSources(hasPreset); err != nil { return err } - fromInputSource := addFromEnvFlag || strings.TrimSpace(addFromFileFlag) != "" + fromInputSource := addFromEnvFlag || strings.TrimSpace(addFromFileFlag) != "" || strings.TrimSpace(addFromTextFlag) != "" if fromInputSource { providerFlagSet = flagWasExplicit(cmd, "provider", false) baseURLFlagSet = flagWasExplicit(cmd, "base-url", false) @@ -245,6 +280,19 @@ func runAdd(cmd *cobra.Command, args []string) error { smallFastModelFlagSet = flagWasExplicit(cmd, "small-fast-model", false) } + // Bootstrap is required whether or not the write path fires: --dry-run + // still needs a Resolver to exist (and, for parity with every other + // command, we do not want a machine without ~/.claudecm to succeed + // silently and then fail later on the first non-dry-run add). + resv, err := resolverFromGlobals() + if err != nil { + return fmt.Errorf("failed to resolve HOME: %w", err) + } + if err := storage.Bootstrap(resv); err != nil { + return fmt.Errorf("failed to bootstrap ~/.claudecm layout: %w", err) + } + store := storage.NewFileStorage(resv) + provider := addProviderFlag baseURL := addBaseURLFlag apiKey := addAPIKeyFlag @@ -284,6 +332,19 @@ func runAdd(cmd *cobra.Command, args []string) error { model = core.Model smallFastModel = core.SmallFastModel } + if strings.TrimSpace(addFromTextFlag) != "" { + core, err := profileDraftFromText(cmd, store) + if err != nil { + return err + } + if core.Provider != "" { + provider = core.Provider + } + baseURL = core.BaseURL + apiKey = core.APIKey + model = core.Model + smallFastModel = core.SmallFastModel + } if providerFlagSet { provider = addProviderFlag } @@ -309,7 +370,7 @@ func runAdd(cmd *cobra.Command, args []string) error { if hasPreset && addAPIKeyFlag == "" { return fmt.Errorf("preset %q requires --api-key in non-interactive add", preset.Name) } - if (addFromEnvFlag || strings.TrimSpace(addFromFileFlag) != "") && strings.TrimSpace(apiKey) == "" { + if fromInputSource && strings.TrimSpace(apiKey) == "" { return fmt.Errorf("no API key found in input source") } @@ -338,19 +399,6 @@ func runAdd(cmd *cobra.Command, args []string) error { Tools: tools, } - // Bootstrap is required whether or not the write path fires: --dry-run - // still needs a Resolver to exist (and, for parity with every other - // command, we do not want a machine without ~/.claudecm to succeed - // silently and then fail later on the first non-dry-run add). - resv, err := resolverFromGlobals() - if err != nil { - return fmt.Errorf("failed to resolve HOME: %w", err) - } - if err := storage.Bootstrap(resv); err != nil { - return fmt.Errorf("failed to bootstrap ~/.claudecm layout: %w", err) - } - store := storage.NewFileStorage(resv) - if addDryRunFlag { return renderAddDryRun(cmd.OutOrStdout(), format, profile) } @@ -399,6 +447,7 @@ func resolveAddPreset(raw string) (presets.Preset, bool, error) { func validateAddInputSources(hasPreset bool) error { fromFileSet := strings.TrimSpace(addFromFileFlag) != "" + fromTextSet := strings.TrimSpace(addFromTextFlag) != "" count := 0 if hasPreset { count++ @@ -409,8 +458,17 @@ func validateAddInputSources(hasPreset bool) error { if fromFileSet { count++ } + if fromTextSet { + count++ + } if count > 1 { - return fmt.Errorf("choose only one add input source: --preset, --from-env, or --from-file") + return fmt.Errorf("choose only one add input source: --preset, --from-env, --from-file, or --from-text") + } + if addAIFlag && !fromTextSet { + return fmt.Errorf("--ai requires --from-text") + } + if strings.TrimSpace(addAIProfileFlag) != "" && !addAIFlag { + return fmt.Errorf("--ai-profile requires --ai") } return nil } @@ -469,6 +527,137 @@ func profileDraftFromEnv() (config.CoreConfig, map[config.ToolID]config.ToolOver return core, tools, nil } +func profileDraftFromText(cmd *cobra.Command, store *storage.FileStorage) (config.CoreConfig, error) { + text, err := readAddFromText(cmd) + if err != nil { + return config.CoreConfig{}, err + } + parsed := blobparse.Parse(text) + if !addAIFlag { + if !coreHasRecognizableField(parsed.Core) { + return config.CoreConfig{}, fmt.Errorf("could not extract profile fields from text") + } + return normalizeParsedProvider(parsed.Core), nil + } + + if err := aiparse.EnsureSecretFree(parsed.Desensitized); err != nil { + return config.CoreConfig{}, err + } + if !isTerminal(os.Stdin) { + return config.CoreConfig{}, fmt.Errorf("--ai requires an interactive terminal to review the desensitized payload before sending; it is not available in non-interactive/piped mode") + } + lenderName, creds, err := resolveAddAICredentials(store) + if err != nil { + return config.CoreConfig{}, err + } + fmt.Fprintf(cmd.OutOrStdout(), "--ai will borrow credentials from profile %q.\n", lenderName) + fmt.Fprintln(cmd.OutOrStdout(), "--- desensitized payload to send ---") + fmt.Fprintln(cmd.OutOrStdout(), parsed.Desensitized) + fmt.Fprintln(cmd.OutOrStdout(), "--- end desensitized payload ---") + ok, promptErr := promptConfirm(cmd.OutOrStdout(), os.Stdin, "Send this secret-free payload for --ai parsing?") + if promptErr != nil { + return config.CoreConfig{}, fmt.Errorf("read confirmation: %w", promptErr) + } + if !ok { + return config.CoreConfig{}, fmt.Errorf("--ai parse refused by user") + } + + aiCore, err := newAddLLMParser().Parse(context.Background(), parsed.Desensitized, creds) + if err != nil { + return config.CoreConfig{}, err + } + aiCore = normalizeParsedProvider(aiCore) + aiCore.APIKey, err = reinjectParsedAPIKey(aiCore.APIKey, parsed.CapturedSecrets) + if err != nil { + return config.CoreConfig{}, err + } + if !coreHasRecognizableField(aiCore) { + return config.CoreConfig{}, fmt.Errorf("--ai parse response contained no profile fields") + } + return aiCore, nil +} + +func readAddFromText(cmd *cobra.Command) (string, error) { + raw := strings.TrimSpace(addFromTextFlag) + if raw == "" { + return "", fmt.Errorf("--from-text requires text or '-'") + } + if raw != "-" { + return addFromTextFlag, nil + } + body, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return "", fmt.Errorf("read --from-text stdin: %w", err) + } + if strings.TrimSpace(string(body)) == "" { + return "", fmt.Errorf("--from-text stdin was empty") + } + return string(body), nil +} + +func resolveAddAICredentials(store *storage.FileStorage) (string, aiparse.Credentials, error) { + if store == nil { + return "", aiparse.Credentials{}, fmt.Errorf("no credentials available for --ai parse") + } + name := strings.TrimSpace(addAIProfileFlag) + if name == "" { + state, err := store.LoadState() + if err != nil { + return "", aiparse.Credentials{}, fmt.Errorf("failed to read active profile for --ai parse: %w", err) + } + name = strings.TrimSpace(state.CurrentProfile) + if name == "" { + return "", aiparse.Credentials{}, fmt.Errorf("no credentials available for --ai parse: no active profile set and --ai-profile was not provided") + } + } + profile, err := store.LoadProfile(name) + if err != nil { + return "", aiparse.Credentials{}, fmt.Errorf("profile %q for --ai parse could not be loaded: %w", name, err) + } + if strings.TrimSpace(profile.Core.Provider) != "" && strings.TrimSpace(profile.Core.Provider) != addProviderDefault { + return "", aiparse.Credentials{}, fmt.Errorf("--ai currently supports only Anthropic-compatible messages endpoints") + } + if strings.TrimSpace(profile.Core.BaseURL) == "" || strings.TrimSpace(profile.Core.APIKey) == "" { + return "", aiparse.Credentials{}, fmt.Errorf("no credentials available for --ai parse: profile %q is missing base_url or api_key", name) + } + return name, aiparse.Credentials{ + BaseURL: profile.Core.BaseURL, + APIKey: profile.Core.APIKey, + Model: profile.Core.Model, + }, nil +} + +func reinjectParsedAPIKey(apiKey string, captured map[string]string) (string, error) { + if secret, ok := captured[apiKey]; ok { + return secret, nil + } + if strings.TrimSpace(apiKey) != "" { + return "", fmt.Errorf("--ai parse response api_key did not match a locally captured secret placeholder") + } + if len(captured) == 1 { + for _, secret := range captured { + return secret, nil + } + } + return "", nil +} + +func normalizeParsedProvider(core config.CoreConfig) config.CoreConfig { + switch core.Provider { + case "openai", "openai-compatible": + core.Provider = "openai-compat" + } + return core +} + +func coreHasRecognizableField(core config.CoreConfig) bool { + return core.BaseURL != "" || + core.APIKey != "" || + core.Model != "" || + core.SmallFastModel != "" || + core.Provider != "" +} + func lookupNonEmptyEnv(name string) string { v, ok := envextract.Lookup(name) if !ok { diff --git a/cmd/add_from_text_ai_test.go b/cmd/add_from_text_ai_test.go new file mode 100644 index 0000000..2d1de54 --- /dev/null +++ b/cmd/add_from_text_ai_test.go @@ -0,0 +1,597 @@ +package cmd + +import ( + "bytes" + "context" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/a2d2-dev/claudecm/internal/aiparse" + "github.com/a2d2-dev/claudecm/internal/config" +) + +func TestAdd_FromTextDryRunRedactsAndDoesNotWrite(t *testing.T) { + h := newAddHarness(t) + addFromTextFlag = strings.Join([]string{ + "export ANTHROPIC_BASE_URL=https://text.example.com", + "export ANTHROPIC_AUTH_TOKEN=sk-text-token-1234", + "export ANTHROPIC_MODEL=claude-text-model", + }, "\n") + addDryRunFlag = true + + stdout, _, err := runAddInner(t, "textprof") + if err != nil { + t.Fatalf("runAdd --from-text: %v", err) + } + for _, want := range []string{ + "base_url: https://text.example.com", + "model: claude-text-model", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("dry-run missing %q:\n%s", want, stdout) + } + } + if strings.Contains(stdout, "sk-text-token-1234") { + t.Fatalf("dry-run leaked plaintext api key:\n%s", stdout) + } + if !strings.Contains(stdout, "sk-t***1234") { + t.Fatalf("dry-run missing redacted api key:\n%s", stdout) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "textprof.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite --dry-run: %v", statErr) + } +} + +func TestAdd_FromTextStdinAndExplicitFlagsOverride(t *testing.T) { + h := newAddHarness(t) + addFromTextFlag = "-" + addAPIKeyFlag = "sk-flag-text-1234" + addModelFlag = "flag-model" + + stdout, _, err := runAddInnerWithInput(t, + "ANTHROPIC_BASE_URL=https://stdin.example.com\nANTHROPIC_AUTH_TOKEN=sk-stdin-text-1234\nANTHROPIC_MODEL=blob-model\n", + "stdinprof", + ) + if err != nil { + t.Fatalf("runAdd --from-text -: %v\nstdout=%s", err, stdout) + } + loaded, err := h.store.LoadProfile("stdinprof") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if loaded.Core.APIKey != "sk-flag-text-1234" { + t.Fatalf("APIKey = %q, want explicit flag", loaded.Core.APIKey) + } + if loaded.Core.Model != "flag-model" { + t.Fatalf("Model = %q, want explicit flag", loaded.Core.Model) + } +} + +func TestAdd_FromTextNoKeyRefusesUnlessExplicitKey(t *testing.T) { + h := newAddHarness(t) + addFromTextFlag = "ANTHROPIC_BASE_URL=https://text.example.com\nANTHROPIC_MODEL=claude-text-model" + + _, _, err := runAddInner(t, "nokeytext") + if err == nil { + t.Fatalf("--from-text without key accepted") + } + if !strings.Contains(err.Error(), "no API key found in input source") { + t.Fatalf("error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "nokeytext.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite missing text key: %v", statErr) + } + + resetAddFlags() + addFromTextFlag = "ANTHROPIC_BASE_URL=https://text.example.com\nANTHROPIC_MODEL=claude-text-model" + addAPIKeyFlag = "sk-explicit-text-1234" + if _, _, err := runAddInner(t, "textkeyflag"); err != nil { + t.Fatalf("runAdd --from-text explicit key: %v", err) + } + loaded, err := h.store.LoadProfile("textkeyflag") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if loaded.Core.APIKey != "sk-explicit-text-1234" { + t.Fatalf("APIKey = %q", loaded.Core.APIKey) + } +} + +func TestAdd_FromTextNothingRecognizableRefuses(t *testing.T) { + h := newAddHarness(t) + addFromTextFlag = "hello there no usable profile fields" + + _, _, err := runAddInner(t, "nothing") + if err == nil { + t.Fatalf("--from-text with nothing recognizable accepted") + } + if !strings.Contains(err.Error(), "could not extract profile fields from text") { + t.Fatalf("error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "nothing.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite unrecognized text: %v", statErr) + } +} + +func TestAddAI_HappyMockReinjectsSecretAndDryRunRedacts(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + parser := &mockAddLLMParser{ + core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://ai.example.com", + APIKey: "{{CLAUDECM_SECRET_1}}", + Model: "claude-ai-model", + }, + } + restore := SetAddLLMParserForTest(func() addLLMParser { return parser }) + t.Cleanup(restore) + addFromTextFlag = "please configure base url https://ai.example.com with token sk-ai-input-1234 model claude-ai-model" + addAIFlag = true + addDryRunFlag = true + + stdout, _, err := runAddInnerInteractive(t, "y\n", "aiprof") + if err != nil { + t.Fatalf("runAdd --from-text --ai: %v", err) + } + if parser.calls != 1 { + t.Fatalf("parser calls = %d, want 1", parser.calls) + } + if parser.creds.APIKey != "sk-lender-secret-1234" { + t.Fatalf("parser credential seam did not receive borrowed key") + } + assertNoSecretShapeInText(t, parser.desensitized) + for _, secret := range []string{"sk-ai-input-1234", "sk-lender-secret-1234"} { + if strings.Contains(parser.desensitized, secret) { + t.Fatalf("outbound desensitized payload leaked %q:\n%s", secret, parser.desensitized) + } + } + if strings.Contains(stdout, "sk-ai-input-1234") || strings.Contains(stdout, "sk-lender-secret-1234") { + t.Fatalf("dry-run leaked secret:\n%s", stdout) + } + if !strings.Contains(stdout, "sk-a***1234") { + t.Fatalf("dry-run missing redacted re-injected key:\n%s", stdout) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "aiprof.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite --dry-run: %v", statErr) + } +} + +func TestAddAI_RedactsSecretNamedFieldsBeforeParser(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + parser := &mockAddLLMParser{ + core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.example.com", + APIKey: "{{CLAUDECM_SECRET_1}}", + Model: "claude-sonnet", + }, + } + restore := SetAddLLMParserForTest(func() addLLMParser { return parser }) + t.Cleanup(restore) + addFromTextFlag = strings.Join([]string{ + `Base URL: https://api.example.com`, + `API Key: sk-input-secret-1234`, + `CLIENT_SECRET=prod-secret-value`, + `PASSWORD=plain-password`, + `DATABASE_TOKEN=db-token-value`, + `model claude-sonnet`, + }, "\n") + addAIFlag = true + addDryRunFlag = true + + stdout, _, err := runAddInnerInteractive(t, "y\n", "airedact") + if err != nil { + t.Fatalf("runAdd --from-text --ai: %v", err) + } + if parser.calls != 1 { + t.Fatalf("parser calls = %d, want 1", parser.calls) + } + for _, secret := range []string{ + "sk-input-secret-1234", + "prod-secret-value", + "plain-password", + "db-token-value", + "sk-lender-secret-1234", + } { + if strings.Contains(parser.desensitized, secret) { + t.Fatalf("parser desensitized payload leaked %q:\n%s", secret, parser.desensitized) + } + if strings.Contains(stdout, secret) { + t.Fatalf("dry-run output leaked %q:\n%s", secret, stdout) + } + } + for _, want := range []string{"CLIENT_SECRET={{CLAUDECM_SECRET_", "PASSWORD={{CLAUDECM_SECRET_", "DATABASE_TOKEN={{CLAUDECM_SECRET_"} { + if !strings.Contains(parser.desensitized, want) { + t.Fatalf("parser desensitized payload missing redacted assignment %q:\n%s", want, parser.desensitized) + } + } +} + +func TestAddAI_ExplicitAPIKeyOverridesReinjectedSecret(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + restore := SetAddLLMParserForTest(func() addLLMParser { + return &mockAddLLMParser{core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://ai.example.com", + APIKey: "{{CLAUDECM_SECRET_1}}", + Model: "claude-ai-model", + }} + }) + t.Cleanup(restore) + addFromTextFlag = "API Key: sk-ai-input-1234 Base URL: https://ai.example.com" + addAIFlag = true + addAPIKeyFlag = "sk-explicit-ai-1234" + + if _, _, err := runAddInnerInteractive(t, "y\n", "aiexplicit"); err != nil { + t.Fatalf("runAdd --ai explicit key: %v", err) + } + loaded, err := h.store.LoadProfile("aiexplicit") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if loaded.Core.APIKey != "sk-explicit-ai-1234" { + t.Fatalf("APIKey = %q, want explicit flag", loaded.Core.APIKey) + } +} + +func TestAddAI_EdgeCredentialAndProtocolRefusals(t *testing.T) { + t.Run("no active", func(t *testing.T) { + h := newAddHarness(t) + addFromTextFlag = "API Key: sk-ai-input-1234" + addAIFlag = true + _, _, err := runAddInnerInteractive(t, "y\n", "noactive") + if err == nil || !strings.Contains(err.Error(), "no credentials available for --ai parse") { + t.Fatalf("err = %v", err) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "noactive.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite no active: %v", statErr) + } + }) + + t.Run("missing ai profile", func(t *testing.T) { + newAddHarness(t) + addFromTextFlag = "API Key: sk-ai-input-1234" + addAIFlag = true + addAIProfileFlag = "missing" + _, _, err := runAddInnerInteractive(t, "y\n", "missing") + if err == nil || !strings.Contains(err.Error(), `profile "missing" for --ai parse could not be loaded`) { + t.Fatalf("err = %v", err) + } + }) + + t.Run("non anthropic", func(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "compat", config.CoreConfig{ + Provider: "openai-compat", + BaseURL: "https://api.openai.com/v1", + APIKey: "sk-lender-secret-1234", + Model: "gpt-5", + }, true) + addFromTextFlag = "API Key: sk-ai-input-1234" + addAIFlag = true + _, _, err := runAddInnerInteractive(t, "y\n", "nonanth") + if err == nil || !strings.Contains(err.Error(), "Anthropic-compatible messages endpoints") { + t.Fatalf("err = %v", err) + } + if strings.Contains(err.Error(), "sk-lender-secret-1234") { + t.Fatalf("error leaked credential: %v", err) + } + }) +} + +func TestAddAI_MalformedMockResponseRefusesWithoutWriteAndNoCredentialLeak(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + _, parseErr := aiparse.ParseCoreJSON(`{"base_url":"https://ai.example.com","extra":"nope"}`) + restore := SetAddLLMParserForTest(func() addLLMParser { + return &mockAddLLMParser{err: parseErr} + }) + t.Cleanup(restore) + addFromTextFlag = "API Key: sk-ai-input-1234" + addAIFlag = true + + _, _, err := runAddInnerInteractive(t, "y\n", "badai") + if err == nil { + t.Fatalf("malformed AI output accepted") + } + if strings.Contains(err.Error(), "sk-lender-secret-1234") || strings.Contains(err.Error(), "sk-ai-input-1234") { + t.Fatalf("error leaked secret: %v", err) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "badai.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite malformed AI output: %v", statErr) + } +} + +func TestAddAI_ResponseAPIKeyMustBeCapturedPlaceholder(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + restore := SetAddLLMParserForTest(func() addLLMParser { + return &mockAddLLMParser{core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://ai.example.com", + APIKey: "sk-guessed-by-ai-1234", + Model: "claude-ai-model", + }} + }) + t.Cleanup(restore) + addFromTextFlag = "API Key: sk-ai-input-1234" + addAIFlag = true + + _, _, err := runAddInnerInteractive(t, "y\n", "aiguess") + if err == nil { + t.Fatalf("AI guessed api_key accepted") + } + if !strings.Contains(err.Error(), "did not match a locally captured secret placeholder") { + t.Fatalf("err = %v", err) + } + if strings.Contains(err.Error(), "sk-guessed-by-ai-1234") || strings.Contains(err.Error(), "sk-ai-input-1234") { + t.Fatalf("error leaked secret: %v", err) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "aiguess.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite guessed AI key: %v", statErr) + } +} + +func TestAddAI_RedactsBearerTokenLineBeforeParser(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + parser := &mockAddLLMParser{ + core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.example.com", + APIKey: "{{CLAUDECM_SECRET_1}}", + Model: "claude-sonnet", + }, + } + restore := SetAddLLMParserForTest(func() addLLMParser { return parser }) + t.Cleanup(restore) + addFromTextFlag = strings.Join([]string{ + `Base URL: https://api.example.com`, + `API Key: sk-ai-input-1234`, + `AUTH=Bearer opaque-session-id-123456`, + `model claude-sonnet`, + }, "\n") + addAIFlag = true + addDryRunFlag = true + + stdout, _, err := runAddInnerInteractive(t, "y\n", "aibearer") + if err != nil { + t.Fatalf("runAdd --from-text --ai: %v", err) + } + if parser.calls != 1 { + t.Fatalf("parser calls = %d, want 1", parser.calls) + } + for _, secret := range []string{ + "sk-ai-input-1234", + "Bearer opaque-session-id-123456", + "opaque-session-id-123456", + "sk-lender-secret-1234", + } { + if strings.Contains(parser.desensitized, secret) { + t.Fatalf("outbound desensitized payload leaked %q:\n%s", secret, parser.desensitized) + } + if strings.Contains(stdout, secret) { + t.Fatalf("stdout leaked %q:\n%s", secret, stdout) + } + } + if !strings.Contains(parser.desensitized, "model claude-sonnet") { + t.Fatalf("outbound desensitized payload swallowed next line:\n%s", parser.desensitized) + } +} + +func TestAddAI_RedactsAuthorizationBeforeParser(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + parser := &mockAddLLMParser{ + core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.example.com", + APIKey: "{{CLAUDECM_SECRET_1}}", + Model: "claude-sonnet", + }, + } + restore := SetAddLLMParserForTest(func() addLLMParser { return parser }) + t.Cleanup(restore) + addFromTextFlag = strings.Join([]string{ + `Base URL: https://api.example.com`, + `Authorization: Bearer opaque-session-id-123456`, + `API Key: sk-ai-input-1234`, + `model claude-sonnet`, + }, "\n") + addAIFlag = true + addDryRunFlag = true + + stdout, _, err := runAddInnerInteractive(t, "y\n", "aiauthz") + if err != nil { + t.Fatalf("runAdd --from-text --ai: %v", err) + } + if parser.calls != 1 { + t.Fatalf("parser calls = %d, want 1", parser.calls) + } + for _, secret := range []string{ + "sk-ai-input-1234", + "Bearer opaque-session-id-123456", + "opaque-session-id-123456", + "sk-lender-secret-1234", + } { + if strings.Contains(parser.desensitized, secret) { + t.Fatalf("outbound desensitized payload leaked %q:\n%s", secret, parser.desensitized) + } + if strings.Contains(stdout, secret) { + t.Fatalf("stdout leaked %q:\n%s", secret, stdout) + } + } +} + +func TestAddAI_NonInteractiveRefusesBeforeParser(t *testing.T) { + h := newAddHarness(t) + seedAIProfile(t, h, "lender", config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, true) + parser := &mockAddLLMParser{ + core: config.CoreConfig{ + Provider: "anthropic", + BaseURL: "https://api.example.com", + APIKey: "{{CLAUDECM_SECRET_1}}", + Model: "claude-sonnet", + }, + } + restoreParser := SetAddLLMParserForTest(func() addLLMParser { return parser }) + t.Cleanup(restoreParser) + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return false }) + t.Cleanup(restoreTTY) + addFromTextFlag = strings.Join([]string{ + `Base URL: https://api.example.com`, + `API Key: sk-ai-input-1234`, + `model claude-sonnet`, + }, "\n") + addAIFlag = true + + _, _, err := runAddInner(t, "ainontty") + if err == nil { + t.Fatalf("non-interactive --ai accepted") + } + if !strings.Contains(err.Error(), "--ai requires an interactive terminal") { + t.Fatalf("err = %v", err) + } + if parser.calls != 0 { + t.Fatalf("parser calls = %d, want 0", parser.calls) + } + if _, statErr := os.Stat(filepath.Join(h.home, ".claudecm", "profiles", "ainontty.yaml")); !os.IsNotExist(statErr) { + t.Fatalf("profile file written despite non-interactive refusal: %v", statErr) + } +} + +func runAddInnerWithInput(t *testing.T, input string, args ...string) (stdout, stderr string, err error) { + t.Helper() + var out, errBuf bytes.Buffer + cmd := &cobra.Command{Use: "add"} + bindSyntheticAddFlags(cmd) + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetIn(strings.NewReader(input)) + err = runAdd(cmd, args) + return out.String(), errBuf.String(), err +} + +func runAddInnerInteractive(t *testing.T, input string, args ...string) (stdout, stderr string, err error) { + t.Helper() + restoreTTY := SetIsTerminalForTest(func(*os.File) bool { return true }) + t.Cleanup(restoreTTY) + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatalf("os.Pipe: %v", pipeErr) + } + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = orig }) + if _, writeErr := w.WriteString(input); writeErr != nil { + t.Fatalf("write stdin pipe: %v", writeErr) + } + _ = w.Close() + return runAddInner(t, args...) +} + +type mockAddLLMParser struct { + calls int + desensitized string + creds aiparse.Credentials + core config.CoreConfig + err error +} + +func (m *mockAddLLMParser) Parse(ctx context.Context, desensitized string, creds aiparse.Credentials) (config.CoreConfig, error) { + m.calls++ + m.desensitized = desensitized + m.creds = creds + if m.err != nil { + return config.CoreConfig{}, m.err + } + return m.core, nil +} + +func seedAIProfile(t *testing.T, h *addHarness, name string, core config.CoreConfig, active bool) { + t.Helper() + p := config.NewProfile(name, core.BaseURL, core.APIKey) + p.Core.Provider = core.Provider + p.Core.Model = core.Model + p.Core.SmallFastModel = core.SmallFastModel + p.CreatedAt = nowFn().UTC() + p.UpdatedAt = p.CreatedAt + if err := h.store.SaveProfile(p); err != nil { + t.Fatalf("SaveProfile(%q): %v", name, err) + } + if active { + state, err := h.store.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + state.SetCurrentProfile(name) + if err := h.store.SaveState(state); err != nil { + t.Fatalf("SaveState: %v", err) + } + } +} + +func assertNoSecretShapeInText(t *testing.T, text string) { + t.Helper() + detectors := []*regexp.Regexp{ + regexp.MustCompile(`(^|[^A-Za-z0-9_-])(sk-[A-Za-z0-9][A-Za-z0-9._=/+-]{3,})`), + regexp.MustCompile(`(^|[^A-Za-z0-9_-])([A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})`), + regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_-])((?:xox[baprs]?|gh[pousr]|pat|token)[_-][A-Za-z0-9][A-Za-z0-9._=-]{7,})`), + regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_-])([A-Za-z0-9._-]{8,}token[A-Za-z0-9._-]{8,})`), + regexp.MustCompile(`(^|[^A-Za-z0-9_-])(AIza[0-9A-Za-z_-]{35,})`), + regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_-])([0-9a-f]{40})(?:$|[^0-9a-f])`), + } + for _, re := range detectors { + if re.FindStringIndex(text) != nil { + t.Fatalf("text contains secret shape:\n%s", text) + } + } +} diff --git a/cmd/add_test.go b/cmd/add_test.go index b81f625..5c80840 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -47,6 +47,9 @@ func resetAddFlags() { addPresetFlag = "" addFromEnvFlag = false addFromFileFlag = "" + addFromTextFlag = "" + addAIFlag = false + addAIProfileFlag = "" addListPresetsFlag = false addDryRunFlag = false addOverwriteFlag = false diff --git a/docs/decisions/0003-smart-add-scope.md b/docs/decisions/0003-smart-add-scope.md index f72463e..6ec28df 100644 --- a/docs/decisions/0003-smart-add-scope.md +++ b/docs/decisions/0003-smart-add-scope.md @@ -1,6 +1,6 @@ # ADR-0003: Smart `add` Onboarding Scope (env / file / paste, optional AI-assisted parse) -- **Status:** Proposed (awaiting CEO/LF sign-off) +- **Status:** Accepted (CEO-delegated 2026-07-08). The single network amendment (the opt-in, interactive-only, secret-free `--ai` parse) is flagged for LF ratification; every other E13 path is zero-network and within existing ADR-0001 scope. - **Date:** 2026-07-08 - **Owner:** CEO (LF) - **Authority:** This memo governs the "smart api-key onboarding" scope (epic E13) only. Where it conflicts with `docs/decisions/0001-direction-lock.md` or `docs/decisions/0002-v1_1-scope.md`, this memo wins **for E13 only**. ADR-0001 and ADR-0002 remain the authority everywhere else. @@ -42,9 +42,10 @@ zero-network paths are the default and the network path is explicit opt-in: 3. **`add --from-text` / `--from-text -` (stdin)** — run the shared **local redaction + heuristic extractor** over pasted text and produce a profile draft. Local only. 4. **`add --ai`** — escalation, **off by default**. Uses the shared extractor to strip and hold - secrets locally, sends only the desensitized text to an LLM, receives a structured profile, - re-injects the held secret locally, then routes through the normal `add` preview/validation. - This is the only E13 path that uses the network. + secrets locally, requires an interactive TTY to show and confirm the full desensitized payload, + sends only the confirmed desensitized text to an LLM, receives a structured profile, re-injects + the held secret locally, then routes through the normal `add` preview/validation. This is the + only E13 path that uses the network. All four paths converge on the existing `add` pipeline: they only produce a `config.Profile` draft, which is then subject to the same `--dry-run`, redaction (NFR-S8), name validation (NFR-S5), @@ -55,9 +56,10 @@ directly, and no path auto-activates the new profile (activation stays `switch`) 1. **ADR-0001 Decision 8 ("no cloud") is narrowly amended for E13's `--ai` path only.** claudecm MAY make a single outbound LLM request to parse desensitized text into a profile draft, strictly - when the user opts in via `--ai`. This does not admit cloud sync, telemetry, a proxy, a gateway, - remote credential validation, or any always-on network behavior. Every non-`--ai` path, and the - default of every path, remains zero-network. + when the user opts in via `--ai` and confirms the desensitized payload in an interactive TTY. + This does not admit cloud sync, telemetry, a proxy, a gateway, remote credential validation, or + any always-on network behavior. Every non-`--ai` path, every non-interactive `--ai` invocation, + and the default of every path remains zero-network. 2. **README "never sees a request" is refined, not revoked.** The tool still never sees, proxies, or routes a *tool* request (Claude Code / Codex traffic). The `--ai` path issues its own, user-initiated, secret-free parse request and nothing else. Docs MUST state this distinction @@ -72,7 +74,9 @@ directly, and no path auto-activates the new profile (activation stays `switch`) have been removed/placeholdered. Re-injection of the real secret happens locally after the LLM responds. A verifiable redaction step (secret token → placeholder) is an acceptance gate, not a nicety. If redaction cannot be established for a candidate secret, that token is stripped - entirely rather than risk transit (no fallback that leaks). + entirely rather than risk transit (no fallback that leaks). `--ai` MUST require interactive + confirmation of the full desensitized payload before the parse request; non-interactive or piped + invocations refuse before sending. 2. **AI is opt-in and off by default.** Absent `--ai`, `add --from-text` uses only the local heuristic and never touches the network. `--ai` must be typed explicitly per invocation; there is no persisted "always use AI" mode in E13. @@ -112,8 +116,8 @@ rule is what makes the AI path defensible against ADR-0001's spirit even while a - **R1. Redaction miss → secret leak on the `--ai` path.** This is the load-bearing risk. Mitigation: redaction is a tested gate with a conservative "strip on doubt" rule; `--ai` shows the - exact desensitized payload before sending when interactive; unit tests assert no secret-shaped - token survives into the outbound request. + exact desensitized payload before sending and requires interactive confirmation; non-interactive + runs refuse; unit tests assert no secret-shaped token survives into the outbound request. - **R2. Heuristic false extraction** (wrong field grabbed). Mitigation: every path ends in `--dry-run`/preview with redacted secrets; nothing is saved without confirmation. - **R3. Scope creep toward "AI everywhere."** Mitigation: `--ai` is per-invocation, single-request, diff --git a/docs/plan/stories/E13-S5.md b/docs/plan/stories/E13-S5.md index d0452b2..25e03e9 100644 --- a/docs/plan/stories/E13-S5.md +++ b/docs/plan/stories/E13-S5.md @@ -9,7 +9,7 @@ As a user whose pasted blob is too messy for the local heuristic, I want `claude - Credentials for the parse call default to the currently-active claudecm profile's `base_url`/`api_key`; `--ai-profile ` overrides which profile lends credentials. The borrowed credential is never logged or persisted. - The LLM is asked to return a structured profile (JSON conforming to the core-field schema). Non-conforming output is refused (NFR-S1); no guessed/half profile is written. - After a conforming response, the held secret is re-injected locally into the draft, then the normal `add` preview/validation/overwrite/save runs; secrets redacted in preview; no auto-activation. -- Interactive runs name the credential-lending profile and display the exact desensitized payload before sending; non-interactive runs proceed only with `--ai` explicitly set. +- `--ai` is available only in an interactive TTY. It names the credential-lending profile and displays the exact desensitized payload for confirmation before sending; non-interactive or piped runs refuse before any parse request is sent. - No remote provider probe/validation of the resulting key or model. **PRD/architecture refs.** ADR-0003 Amendments 1-2, Locked Decisions 1-4; PRD FR-1, NFR-S1, NFR-S8; ADR-0001 Decision 8 (as amended by ADR-0003 for this path only). diff --git a/docs/quickstart.md b/docs/quickstart.md index 9f4e664..14bc02e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -77,6 +77,26 @@ claudecm add work --preset moonshot --api-key sk-ant-xxxxxxxx Available presets: `moonshot`, `deepseek`, `glm`, `qwen`. Use `claudecm add --list-presets` to inspect the current catalog. Endpoint and model names can drift, so override with `--base-url`, `--model`, `--provider`, or `--set` when a provider changes its API. +You can also build a draft from pasted text. This is local by default: `--from-text` runs the local extractor, redacts secrets in `--dry-run`, and does not use the network. + +```bash +claudecm add work \ + --from-text 'ANTHROPIC_BASE_URL=https://api.anthropic.com ANTHROPIC_AUTH_TOKEN=sk-ant-xxxxxxxx ANTHROPIC_MODEL=claude-opus-4-5' \ + --dry-run +``` + +Use `--from-text -` to read stdin: + +```bash +cat provider-snippet.txt | claudecm add work --from-text - --dry-run +``` + +If the local extractor is not enough, `--ai` is opt-in per invocation and only runs in an interactive terminal. claudecm strips secret-shaped tokens locally, keeps captured secrets in-process, shows the exact desensitized payload for confirmation, and sends only the confirmed desensitized text in one Anthropic-compatible Messages request using the active profile's credentials, or `--ai-profile ` if you choose another credential-lending profile. Non-interactive or piped `--ai` runs refuse before sending. + +```bash +claudecm add work --from-text 'messy provider note with sk-ant-xxxxxxxx' --ai --dry-run +``` + > **Name rules.** Profile names must match `^[a-z0-9][a-z0-9._-]{0,63}$` (NFR-S5). If `claudecm add` fails with a profile-name error, that regex is the reason — no uppercase, no leading dot/dash, ≤ 64 characters. ## 4. Switch to the second profile diff --git a/internal/adapter/claudecode/fixtures_test.go b/internal/adapter/claudecode/fixtures_test.go index f46dc0a..5992d06 100644 --- a/internal/adapter/claudecode/fixtures_test.go +++ b/internal/adapter/claudecode/fixtures_test.go @@ -223,10 +223,10 @@ type fixtureCase struct { // path. Kept as separate bools rather than an enum so an unknown // value in a future fixture is a compile-time addition, not a // silent default. - SymlinkInHome bool // ~/.claude/settings.json → ~/.claude/settings-actual.json - SymlinkOutOfHome bool // ~/.claude/settings.json → outside-HOME real file - ErrorOnlyErrName string // expected error identifier when ErrorOnly is true - ErrorOnly bool // true → assert Import error, skip all stage compares + SymlinkInHome bool // ~/.claude/settings.json → ~/.claude/settings-actual.json + SymlinkOutOfHome bool // ~/.claude/settings.json → outside-HOME real file + ErrorOnlyErrName string // expected error identifier when ErrorOnly is true + ErrorOnly bool // true → assert Import error, skip all stage compares } // discoverCases walks classes then names to build the case slice. diff --git a/internal/aiparse/parse.go b/internal/aiparse/parse.go new file mode 100644 index 0000000..a1e3524 --- /dev/null +++ b/internal/aiparse/parse.go @@ -0,0 +1,475 @@ +// Package aiparse sends a secret-free profile parse request to an +// Anthropic-compatible Messages endpoint. +package aiparse + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/a2d2-dev/claudecm/internal/config" +) + +const ( + defaultMaxTokens = 1024 + defaultAnthropicVersion = "2023-06-01" +) + +// Credentials are borrowed from an existing claudecm profile for one parse +// request. Callers must not log or persist APIKey. +type Credentials struct { + BaseURL string + APIKey string + Model string +} + +// HTTPDoer is the narrow transport seam used by Client. +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client is the production Anthropic-compatible parser. +type Client struct { + doer HTTPDoer +} + +// NewClient returns a Client using doer. Passing nil uses http.DefaultClient. +func NewClient(doer HTTPDoer) *Client { + if doer == nil { + doer = http.DefaultClient + } + return &Client{doer: doer} +} + +// Parse sends desensitized text to the borrowed profile's Messages endpoint +// and returns the strict core-field JSON response. +func (c *Client) Parse(ctx context.Context, desensitized string, creds Credentials) (config.CoreConfig, error) { + if strings.TrimSpace(desensitized) == "" { + return config.CoreConfig{}, fmt.Errorf("desensitized text is empty") + } + if strings.TrimSpace(creds.BaseURL) == "" || strings.TrimSpace(creds.APIKey) == "" { + return config.CoreConfig{}, fmt.Errorf("no credentials available for --ai parse") + } + if strings.TrimSpace(creds.Model) == "" { + return config.CoreConfig{}, fmt.Errorf("credential-lending profile has no model for --ai parse") + } + if err := EnsureSecretFree(desensitized); err != nil { + return config.CoreConfig{}, err + } + + endpoint, err := messagesEndpoint(creds.BaseURL) + if err != nil { + return config.CoreConfig{}, err + } + + body, err := json.Marshal(messagesRequest{ + Model: creds.Model, + MaxTokens: defaultMaxTokens, + Temperature: 0, + System: systemPrompt(), + Messages: []message{{ + Role: "user", + Content: []contentBlock{{ + Type: "text", + Text: userPrompt(desensitized), + }}, + }}, + }) + if err != nil { + return config.CoreConfig{}, fmt.Errorf("marshal --ai parse request: %w", err) + } + if err := EnsureSecretFree(string(body)); err != nil { + return config.CoreConfig{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return config.CoreConfig{}, fmt.Errorf("build --ai parse request: %w", err) + } + req.Header.Set("content-type", "application/json") + req.Header.Set("accept", "application/json") + req.Header.Set("x-api-key", creds.APIKey) + req.Header.Set("anthropic-version", defaultAnthropicVersion) + + resp, err := c.doer.Do(req) + if err != nil { + return config.CoreConfig{}, fmt.Errorf("--ai parse request failed") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + io.Copy(io.Discard, resp.Body) + return config.CoreConfig{}, fmt.Errorf("--ai parse request failed with HTTP status %d", resp.StatusCode) + } + + limited := io.LimitReader(resp.Body, 1<<20) + var parsed messagesResponse + dec := json.NewDecoder(limited) + if err := dec.Decode(&parsed); err != nil { + return config.CoreConfig{}, fmt.Errorf("--ai parse response was not valid Anthropic messages JSON") + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return config.CoreConfig{}, fmt.Errorf("--ai parse response had trailing data") + } + + text, err := responseText(parsed) + if err != nil { + return config.CoreConfig{}, err + } + core, err := ParseCoreJSON(text) + if err != nil { + return config.CoreConfig{}, err + } + if !coreHasAnyField(core) { + return config.CoreConfig{}, fmt.Errorf("--ai parse response contained no profile fields") + } + return core, nil +} + +func messagesEndpoint(rawBaseURL string) (string, error) { + base, err := url.Parse(strings.TrimSpace(rawBaseURL)) + if err != nil { + return "", fmt.Errorf("invalid --ai credential base URL") + } + if base.Scheme != "http" && base.Scheme != "https" || base.Host == "" { + return "", fmt.Errorf("invalid --ai credential base URL") + } + base.User = nil + base.RawQuery = "" + base.Fragment = "" + path := strings.TrimRight(base.Path, "/") + switch { + case path == "": + base.Path = "/v1/messages" + case strings.HasSuffix(path, "/v1/messages"): + base.Path = path + case strings.HasSuffix(path, "/v1"): + base.Path = path + "/messages" + default: + base.Path = path + "/v1/messages" + } + return base.String(), nil +} + +func systemPrompt() string { + return strings.Join([]string{ + "Return only a JSON object with these optional string fields:", + "base_url, api_key, model, small_fast_model, provider.", + "Use only values present in the user's text.", + "If the API key is represented by a placeholder like {{CLAUDECM_SECRET_1}}, return that placeholder as api_key.", + "Do not include markdown, comments, explanations, nulls, arrays, nested objects, or extra fields.", + }, " ") +} + +func userPrompt(desensitized string) string { + return "Parse this claudecm profile source text:\n" + desensitized +} + +// EnsureSecretFree refuses text that still contains a secret-shaped token. +func EnsureSecretFree(text string) error { + if secretShapePresent(text) { + return fmt.Errorf("refusing to send --ai parse payload: desensitized text still contains a secret-shaped token") + } + if secretNamedAssignmentPresent(text) { + return fmt.Errorf("refusing to send --ai parse payload: desensitized text still contains a secret-named assignment") + } + return nil +} + +func secretShapePresent(text string) bool { + for _, corePattern := range secretShapePatterns() { + re := regexp.MustCompile(`(^|[^A-Za-z0-9_-])(` + corePattern + `)`) + if re.FindStringIndex(text) != nil { + return true + } + } + for _, rawToken := range strings.Fields(text) { + token := normalizeSecretToken(rawToken) + if key, value, ok := strings.Cut(token, "="); ok && isAssignmentKey(key) && value != "" { + token = normalizeSecretToken(value) + } + if isHighEntropySecretToken(token) { + return true + } + } + return false +} + +func secretNamedAssignmentPresent(text string) bool { + for _, assignment := range secretNamedAssignments(text) { + value := cleanAssignmentValue(assignment.value) + if isSecretFieldName(assignment.name) && value != "" && !secretNamedValueIsPlaceholdered(value) { + return true + } + } + return false +} + +type secretNamedAssignment struct { + name string + value string +} + +func secretNamedAssignments(text string) []secretNamedAssignment { + prefixRe := regexp.MustCompile(`(?i)(^|[\s{[,;])(?:export[ \t]+)?["']?([A-Za-z][A-Za-z0-9 _-]{0,80})["']?[ \t]*[:=][ \t]*`) + var out []secretNamedAssignment + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSuffix(line, "\r") + matches := prefixRe.FindAllStringSubmatchIndex(line, -1) + for _, match := range matches { + if len(match) < 6 || match[4] < 0 || match[5] < 0 { + continue + } + out = append(out, secretNamedAssignment{ + name: line[match[4]:match[5]], + value: assignmentLineValue(line[match[1]:]), + }) + } + } + return out +} + +func assignmentLineValue(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + switch value[0] { + case '"': + return consumeQuotedValue(value, '"', true) + case '\'': + return consumeQuotedValue(value, '\'', false) + case '`': + return consumeQuotedValue(value, '`', false) + default: + return value + } +} + +func consumeQuotedValue(value string, quote byte, allowEscape bool) string { + escaped := false + for i := 1; i < len(value); i++ { + if allowEscape && !escaped && value[i] == '\\' { + escaped = true + continue + } + if !escaped && value[i] == quote { + return value[:i+1] + } + escaped = false + } + return value +} + +func isSecretFieldName(name string) bool { + fields := normalizedNameFields(name) + if len(fields) == 0 { + return false + } + compact := strings.Join(fields, "") + for _, marker := range secretFieldNameMarkers() { + if compact == marker || strings.HasPrefix(compact, marker) || strings.Contains(compact, marker) { + return true + } + } + return false +} + +func secretFieldNameMarkers() []string { + return []string{ + "secret", + "password", + "passwd", + "pwd", + "token", + "apikey", + "auth", + "authorization", + "credential", + "credentials", + "privatekey", + "accesskey", + "clientsecret", + } +} + +func normalizedNameFields(name string) []string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(name)) { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': + b.WriteRune(r) + case r == '_' || r == '-' || r == ' ' || r == '\t': + b.WriteByte(' ') + } + } + return strings.Fields(b.String()) +} + +func cleanAssignmentValue(raw string) string { + value := strings.TrimSpace(raw) + if isSecretPlaceholder(value) { + return value + } + switch { + case strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`): + value = strings.Trim(value, `"`) + case strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'"): + value = strings.Trim(value, "'") + case strings.HasPrefix(value, "`") && strings.HasSuffix(value, "`"): + value = strings.Trim(value, "`") + default: + value = strings.TrimRight(value, ".,)]") + } + return strings.TrimSpace(value) +} + +func isSecretPlaceholder(value string) bool { + return regexp.MustCompile(`^\{\{CLAUDECM_SECRET_[0-9]+\}\}$`).MatchString(strings.TrimSpace(value)) +} + +func secretNamedValueIsPlaceholdered(value string) bool { + if isSecretPlaceholder(value) { + return true + } + return regexp.MustCompile(`\{\{CLAUDECM_SECRET_[0-9]+\}\}`).FindStringIndex(value) != nil && + !secretShapePresent(value) +} + +func secretShapePatterns() []string { + return []string{ + `sk-[A-Za-z0-9][A-Za-z0-9._=/+-]{3,}`, + `[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`, + `(?i)(?:xox[baprs]?|gh[pousr]|pat|token)[_-][A-Za-z0-9][A-Za-z0-9._=-]{7,}`, + `(?i)[A-Za-z0-9._-]{8,}token[A-Za-z0-9._-]{8,}`, + `AIza[0-9A-Za-z_-]{35}`, + `(?i)[0-9a-f]{40}`, + } +} + +func isAssignmentKey(text string) bool { + for _, r := range text { + switch { + case r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_': + default: + return false + } + } + return text != "" +} + +func isHighEntropySecretToken(token string) bool { + if len(token) < 32 || looksLikeURL(token) { + return false + } + hasLetter := false + hasDigit := false + for _, r := range token { + switch { + case r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z': + hasLetter = true + case r >= '0' && r <= '9': + hasDigit = true + case r == '+' || r == '/' || r == '=' || r == '_' || r == '-': + default: + return false + } + } + return hasLetter && hasDigit +} + +func looksLikeURL(token string) bool { + return strings.Contains(token, "://") || strings.HasPrefix(token, "/") +} + +func normalizeSecretToken(secret string) string { + return strings.Trim(strings.TrimSpace(secret), `"'`+"`"+`.,;:()[]{}<>`) +} + +// ParseCoreJSON parses the LLM's strict core JSON object. +func ParseCoreJSON(text string) (config.CoreConfig, error) { + dec := json.NewDecoder(strings.NewReader(strings.TrimSpace(text))) + dec.DisallowUnknownFields() + var raw map[string]string + if err := dec.Decode(&raw); err != nil { + return config.CoreConfig{}, fmt.Errorf("--ai parse response was not strict core JSON") + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return config.CoreConfig{}, fmt.Errorf("--ai parse response had trailing data") + } + var core config.CoreConfig + for key, value := range raw { + switch key { + case "base_url": + core.BaseURL = value + case "api_key": + core.APIKey = value + case "model": + core.Model = value + case "small_fast_model": + core.SmallFastModel = value + case "provider": + core.Provider = value + default: + return config.CoreConfig{}, fmt.Errorf("--ai parse response contained unsupported field %q", key) + } + } + return core, nil +} + +func coreHasAnyField(core config.CoreConfig) bool { + return core.BaseURL != "" || + core.APIKey != "" || + core.Model != "" || + core.SmallFastModel != "" || + core.Provider != "" +} + +type messagesRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature"` + System string `json:"system"` + Messages []message `json:"messages"` +} + +type message struct { + Role string `json:"role"` + Content []contentBlock `json:"content"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type messagesResponse struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Role string `json:"role,omitempty"` + Model string `json:"model,omitempty"` + Content []contentBlock `json:"content"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` + Usage json.RawMessage `json:"usage,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +func responseText(resp messagesResponse) (string, error) { + var parts []string + for _, block := range resp.Content { + if block.Type == "text" { + parts = append(parts, block.Text) + } + } + if len(parts) == 0 { + return "", fmt.Errorf("--ai parse response contained no text block") + } + return strings.TrimSpace(strings.Join(parts, "\n")), nil +} diff --git a/internal/aiparse/parse_test.go b/internal/aiparse/parse_test.go new file mode 100644 index 0000000..22ef6d4 --- /dev/null +++ b/internal/aiparse/parse_test.go @@ -0,0 +1,217 @@ +package aiparse + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +func TestClientParseSendsSecretFreePayloadAndDoesNotLeakCredentialInError(t *testing.T) { + doer := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got := req.Header.Get("x-api-key"); got != "sk-lender-secret-1234" { + t.Fatalf("x-api-key = %q", got) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll body: %v", err) + } + for _, secret := range []string{ + "sk-input-secret-1234", + "ghp_1234567890abcdef", + "sk-lender-secret-1234", + } { + if strings.Contains(string(body), secret) { + t.Fatalf("outbound payload leaked %q:\n%s", secret, body) + } + } + if !strings.Contains(string(body), "{{CLAUDECM_SECRET_1}}") { + t.Fatalf("outbound payload missing placeholder:\n%s", body) + } + return jsonResponse(200, `{"content":[{"type":"text","text":"{\"base_url\":\"https://api.example.com\",\"api_key\":\"{{CLAUDECM_SECRET_1}}\",\"model\":\"claude-test\",\"provider\":\"anthropic\"}"}]}`), nil + }) + client := NewClient(doer) + + core, err := client.Parse(context.Background(), + "Base URL: https://api.example.com API Key: {{CLAUDECM_SECRET_1}} and ", + Credentials{ + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, + ) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if core.APIKey != "{{CLAUDECM_SECRET_1}}" { + t.Fatalf("APIKey = %q", core.APIKey) + } +} + +func TestClientParseRefusesOutboundSecretShapeBeforeTransport(t *testing.T) { + called := false + client := NewClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + called = true + return nil, nil + })) + + _, err := client.Parse(context.Background(), + "still has sk-input-secret-1234", + Credentials{ + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, + ) + if err == nil { + t.Fatalf("Parse accepted secret-shaped payload") + } + if called { + t.Fatalf("transport was called despite secret-shaped payload") + } + if strings.Contains(err.Error(), "sk-lender-secret-1234") || strings.Contains(err.Error(), "sk-input-secret-1234") { + t.Fatalf("error leaked secret: %v", err) + } +} + +func TestClientParseRefusesOutboundSecretNamedAssignmentBeforeTransport(t *testing.T) { + called := false + client := NewClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + called = true + return nil, nil + })) + + _, err := client.Parse(context.Background(), + "Base URL: https://api.example.com SECRET=not-a-secret-shape", + Credentials{ + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, + ) + if err == nil { + t.Fatalf("Parse accepted secret-named assignment payload") + } + if called { + t.Fatalf("transport was called despite secret-named assignment payload") + } + if strings.Contains(err.Error(), "not-a-secret-shape") || strings.Contains(err.Error(), "sk-lender-secret-1234") { + t.Fatalf("error leaked secret: %v", err) + } +} + +func TestEnsureSecretFreeRefusesAuthorizationAndAuthLineRemainders(t *testing.T) { + tests := []struct { + name string + text string + }{ + { + name: "authorization bearer", + text: "Base URL: https://api.example.com\nAuthorization: Bearer opaque-session-id-123456\nmodel claude-sonnet", + }, + { + name: "auth bearer", + text: "Base URL: https://api.example.com\nAUTH=Bearer opaque-session-id-123456\nmodel claude-sonnet", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := EnsureSecretFree(tt.text) + if err == nil { + t.Fatalf("EnsureSecretFree accepted residual secret-named assignment") + } + if strings.Contains(err.Error(), "opaque-session-id-123456") { + t.Fatalf("error leaked secret: %v", err) + } + }) + } +} + +func TestClientParseStripsUserinfoFromEndpoint(t *testing.T) { + var gotURL string + client := NewClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotURL = req.URL.String() + if req.URL.User != nil { + t.Fatalf("request URL retained userinfo: %s", req.URL.Redacted()) + } + if strings.Contains(gotURL, "sk-url-secret-123456") || strings.Contains(gotURL, "@") { + t.Fatalf("request URL leaked userinfo: %s", gotURL) + } + return jsonResponse(200, `{"content":[{"type":"text","text":"{\"base_url\":\"https://api.example.com\",\"model\":\"claude-test\"}"}]}`), nil + })) + + if _, err := client.Parse(context.Background(), + "Base URL: https://api.example.com model claude-test", + Credentials{ + BaseURL: "https://user:sk-url-secret-123456@api.anthropic.com/custom?secret=drop#frag", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }, + ); err != nil { + t.Fatalf("Parse: %v", err) + } + if gotURL != "https://api.anthropic.com/custom/v1/messages" { + t.Fatalf("request URL = %q", gotURL) + } +} + +func TestClientParseRefusesMalformedAndNonConformingResponses(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "malformed messages", body: `{`}, + {name: "no text block", body: `{"content":[{"type":"tool_use","text":"{}"}]}`}, + {name: "malformed core json", body: `{"content":[{"type":"text","text":"not json"}]}`}, + {name: "extra field", body: `{"content":[{"type":"text","text":"{\"base_url\":\"https://api.example.com\",\"extra\":\"nope\"}"}]}`}, + {name: "empty object", body: `{"content":[{"type":"text","text":"{}"}]}`}, + {name: "array", body: `{"content":[{"type":"text","text":"[]"}]}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + return jsonResponse(200, tt.body), nil + })) + _, err := client.Parse(context.Background(), "plain desensitized payload", Credentials{ + BaseURL: "https://api.anthropic.com", + APIKey: "sk-lender-secret-1234", + Model: "claude-lender", + }) + if err == nil { + t.Fatalf("Parse accepted %s", tt.name) + } + if strings.Contains(err.Error(), "sk-lender-secret-1234") { + t.Fatalf("error leaked credential: %v", err) + } + }) + } +} + +func TestParseCoreJSONStrictSchema(t *testing.T) { + core, err := ParseCoreJSON(`{"base_url":"https://api.example.com","api_key":"{{CLAUDECM_SECRET_1}}","model":"claude","small_fast_model":"haiku","provider":"anthropic"}`) + if err != nil { + t.Fatalf("ParseCoreJSON: %v", err) + } + if core.BaseURL != "https://api.example.com" || core.APIKey != "{{CLAUDECM_SECRET_1}}" || core.SmallFastModel != "haiku" { + t.Fatalf("Core = %#v", core) + } + + if _, err := ParseCoreJSON(`{"base_url":"https://api.example.com","nested":{"no":"no"}}`); err == nil { + t.Fatalf("ParseCoreJSON accepted nested object") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} diff --git a/internal/blobparse/parse.go b/internal/blobparse/parse.go index 45f30dd..bfcfaf7 100644 --- a/internal/blobparse/parse.go +++ b/internal/blobparse/parse.go @@ -39,6 +39,7 @@ func Parse(text string) Result { registry.placeholderFor(candidate.value) } } + registerSecretNamedFields(text, registry) registerGenericSecrets(text, candidates, registry) desensitized := registry.desensitize(text) @@ -293,6 +294,120 @@ func registerGenericSecrets(text string, candidates []fieldCandidate, registry * registerHighEntropySecrets(text, candidates, registry) } +func registerSecretNamedFields(text string, registry *secretRegistry) { + for _, assignment := range secretNamedAssignments(text) { + if !isSecretFieldName(assignment.name) { + continue + } + value := cleanValue(assignment.value, fieldAPIKey) + if value == "" { + continue + } + registry.placeholderFor(value) + } +} + +type secretNamedAssignment struct { + name string + value string +} + +func secretNamedAssignments(text string) []secretNamedAssignment { + prefixRe := regexp.MustCompile(`(?i)(^|[\s{[,;])(?:export[ \t]+)?["']?([A-Za-z][A-Za-z0-9 _-]{0,80})["']?[ \t]*[:=][ \t]*`) + var out []secretNamedAssignment + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSuffix(line, "\r") + matches := prefixRe.FindAllStringSubmatchIndex(line, -1) + for _, match := range matches { + if len(match) < 6 || match[4] < 0 || match[5] < 0 { + continue + } + out = append(out, secretNamedAssignment{ + name: line[match[4]:match[5]], + value: assignmentLineValue(line[match[1]:]), + }) + } + } + return out +} + +func assignmentLineValue(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + switch value[0] { + case '"': + return consumeQuotedValue(value, '"', true) + case '\'': + return consumeQuotedValue(value, '\'', false) + case '`': + return consumeQuotedValue(value, '`', false) + default: + return value + } +} + +func consumeQuotedValue(value string, quote byte, allowEscape bool) string { + escaped := false + for i := 1; i < len(value); i++ { + if allowEscape && !escaped && value[i] == '\\' { + escaped = true + continue + } + if !escaped && value[i] == quote { + return value[:i+1] + } + escaped = false + } + return value +} + +func isSecretFieldName(name string) bool { + fields := normalizedNameFields(name) + if len(fields) == 0 { + return false + } + compact := strings.Join(fields, "") + for _, marker := range secretFieldNameMarkers() { + if compact == marker || strings.HasPrefix(compact, marker) || strings.Contains(compact, marker) { + return true + } + } + return false +} + +func secretFieldNameMarkers() []string { + return []string{ + "secret", + "password", + "passwd", + "pwd", + "token", + "apikey", + "auth", + "authorization", + "credential", + "credentials", + "privatekey", + "accesskey", + "clientsecret", + } +} + +func normalizedNameFields(name string) []string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(name)) { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': + b.WriteRune(r) + case r == '_' || r == '-' || r == ' ' || r == '\t': + b.WriteByte(' ') + } + } + return strings.Fields(b.String()) +} + func scrubResidualSecretShapes(text string, candidates []fieldCandidate) string { desensitized := text for _, corePattern := range secretShapePatterns() { diff --git a/internal/blobparse/parse_test.go b/internal/blobparse/parse_test.go index 63131b8..58bb3a3 100644 --- a/internal/blobparse/parse_test.go +++ b/internal/blobparse/parse_test.go @@ -32,8 +32,12 @@ func TestParseExtractsFieldsAndDesensitizes(t *testing.T) { wantProvider: "anthropic", }, { - name: "prose labels", - input: `Base URL: https://proxy.example.test/v1, API Key: sk-prose-secret123, model: claude-opus-4`, + name: "prose labels", + input: strings.Join([]string{ + `Base URL: https://proxy.example.test/v1`, + `API Key: sk-prose-secret123`, + `model: claude-opus-4`, + }, "\n"), wantBaseURL: "https://proxy.example.test/v1", wantAPIKey: "sk-prose-secret123", wantModel: "claude-opus-4", @@ -124,6 +128,83 @@ func TestParseRedactsSecretNamedFieldsWithoutSecretShape(t *testing.T) { } } +func TestParseRedactsSecretNamedUnquotedValueToEndOfLine(t *testing.T) { + input := strings.Join([]string{ + `Base URL: https://api.example.com`, + `API Key: sk-ai-input-1234`, + `AUTH=Bearer opaque-session-id-123456`, + `model claude-sonnet`, + }, "\n") + + got := Parse(input) + + for _, secret := range []string{"sk-ai-input-1234", "Bearer opaque-session-id-123456", "opaque-session-id-123456"} { + if strings.Contains(got.Desensitized, secret) { + t.Fatalf("Desensitized leaked %q:\n%s", secret, got.Desensitized) + } + } + if !strings.Contains(got.Desensitized, "model claude-sonnet") { + t.Fatalf("Desensitized swallowed next line:\n%s", got.Desensitized) + } +} + +func TestParseRedactsAuthorizationSecretNamedField(t *testing.T) { + input := strings.Join([]string{ + `Base URL: https://api.example.com`, + `Authorization: Bearer opaque-session-id-123456`, + `model claude-sonnet`, + }, "\n") + + got := Parse(input) + + for _, secret := range []string{"Bearer opaque-session-id-123456", "opaque-session-id-123456"} { + if strings.Contains(got.Desensitized, secret) { + t.Fatalf("Desensitized leaked %q:\n%s", secret, got.Desensitized) + } + } + if !strings.Contains(got.Desensitized, "model claude-sonnet") { + t.Fatalf("Desensitized swallowed next line:\n%s", got.Desensitized) + } +} + +func TestParseRedactsGenericSecretNamedFieldsWithoutSecretShape(t *testing.T) { + input := strings.Join([]string{ + `Base URL: https://api.example.com`, + `CLIENT_SECRET=prod-secret-value`, + `PASSWORD='plain password value'`, + `export DATABASE_TOKEN=db-token-value`, + `"private key": "plain-private-key-value"`, + `model: claude-sonnet`, + }, "\n") + + got := Parse(input) + + if got.Core.BaseURL != "https://api.example.com" { + t.Fatalf("Core.BaseURL = %q", got.Core.BaseURL) + } + if got.Core.Model != "claude-sonnet" { + t.Fatalf("Core.Model = %q", got.Core.Model) + } + for _, secret := range []string{ + "prod-secret-value", + "plain password value", + "db-token-value", + "plain-private-key-value", + } { + if strings.Contains(got.Desensitized, secret) { + t.Fatalf("Desensitized leaked %q:\n%s", secret, got.Desensitized) + } + } + for _, nonSecret := range []string{"https://api.example.com", "claude-sonnet"} { + if !strings.Contains(got.Desensitized, nonSecret) { + t.Fatalf("Desensitized removed non-secret %q:\n%s", nonSecret, got.Desensitized) + } + } + if len(got.CapturedSecrets) != 4 { + t.Fatalf("CapturedSecrets len = %d, want 4: %#v", len(got.CapturedSecrets), got.CapturedSecrets) + } +} + func TestParseNoRecognizableFieldsPreservesPlainText(t *testing.T) { input := "hello there\nthis blob has no profile fields" @@ -141,7 +222,7 @@ func TestParseNoRecognizableFieldsPreservesPlainText(t *testing.T) { } func TestParseReusesStablePlaceholderForRepeatedSecret(t *testing.T) { - input := "ANTHROPIC_AUTH_TOKEN=sk-repeat-secret and API Key: sk-repeat-secret" + input := "ANTHROPIC_AUTH_TOKEN=sk-repeat-secret\nAPI Key: sk-repeat-secret" got := Parse(input) diff --git a/internal/config/schema.go b/internal/config/schema.go index 189bd14..42e5bbc 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -11,14 +11,14 @@ import ( // legacy v0 → v1 migration when the file pre-dates the unified schema. The // decision tree is intentionally narrow: // -// 1. Malformed YAML → error (no fallback writes, NFR-S1). -// 2. schema_version absent → treat as legacy v0; migrate to v1 in-memory. -// The next save will rewrite the file under the v1 shape. -// 3. schema_version == 1 → decode as v1. -// 4. schema_version >= 2 → refuse with a "newer claudecm wrote this" error -// (NFR-M1: never silently misread a future schema). -// 5. Any other value (e.g. negative) -// → refuse: schema version is structurally invalid. +// 1. Malformed YAML → error (no fallback writes, NFR-S1). +// 2. schema_version absent → treat as legacy v0; migrate to v1 in-memory. +// The next save will rewrite the file under the v1 shape. +// 3. schema_version == 1 → decode as v1. +// 4. schema_version >= 2 → refuse with a "newer claudecm wrote this" error +// (NFR-M1: never silently misread a future schema). +// 5. Any other value (e.g. negative) +// → refuse: schema version is structurally invalid. // // MarshalProfile is the symmetric writer; it always stamps // CurrentProfileSchemaVersion on the output. diff --git a/internal/envextract/extractor.go b/internal/envextract/extractor.go index 11c1378..d98d4fc 100644 --- a/internal/envextract/extractor.go +++ b/internal/envextract/extractor.go @@ -8,18 +8,18 @@ import ( // ClaudeEnvVars defines the environment variables used by Claude Code const ( - EnvBaseURL = "ANTHROPIC_BASE_URL" - EnvAuthToken = "ANTHROPIC_AUTH_TOKEN" - EnvModel = "ANTHROPIC_MODEL" - EnvSmallFastModel = "ANTHROPIC_SMALL_FAST_MODEL" + EnvBaseURL = "ANTHROPIC_BASE_URL" + EnvAuthToken = "ANTHROPIC_AUTH_TOKEN" + EnvModel = "ANTHROPIC_MODEL" + EnvSmallFastModel = "ANTHROPIC_SMALL_FAST_MODEL" ) // ExtractedEnv holds the extracted environment variables type ExtractedEnv struct { - BaseURL string - AuthToken string - Model string - SmallFastModel string + BaseURL string + AuthToken string + Model string + SmallFastModel string } // ExtractCurrentEnv extracts Claude-related environment variables from the current environment diff --git a/internal/storage/atomic.go b/internal/storage/atomic.go index 1766fa9..e21a378 100644 --- a/internal/storage/atomic.go +++ b/internal/storage/atomic.go @@ -118,11 +118,11 @@ func Stat(path string) (Fingerprint, bool, error) { // 4. fsync the temp file, close it. // 5. Publish the temp to the final path: // - opts.MustNotExist=false: os.Rename(temp, target). Rename is atomic -// on POSIX and clobbers any pre-existing target as a single step. +// on POSIX and clobbers any pre-existing target as a single step. // - opts.MustNotExist=true: os.Link(temp, target) then os.Remove(temp). -// Link fails atomically with EEXIST if the target already exists, -// eliminating the TOCTOU window a Lstat pre-check would leave open -// between check and rename. On EEXIST we return ErrTargetExists. +// Link fails atomically with EEXIST if the target already exists, +// eliminating the TOCTOU window a Lstat pre-check would leave open +// between check and rename. On EEXIST we return ErrTargetExists. // 6. fsync the parent directory so the rename/link is durable on ext4/xfs. // 7. Return the post-write Fingerprint. // diff --git a/internal/storage/paths_test.go b/internal/storage/paths_test.go index 78b6dd8..ef328c3 100644 --- a/internal/storage/paths_test.go +++ b/internal/storage/paths_test.go @@ -285,4 +285,3 @@ func TestResolver_LexicalToolConfigPath(t *testing.T) { }) } } - diff --git a/internal/writepath/apply_test.go b/internal/writepath/apply_test.go index 8ad5766..ea73a12 100644 --- a/internal/writepath/apply_test.go +++ b/internal/writepath/apply_test.go @@ -933,12 +933,12 @@ func TestApply_ReparseFailureRollsBack_FirstWrite(t *testing.T) { // the subsequent AtomicWrite rollback fails at os.Rename with EISDIR. // The parser then returns an error to trigger rollback. type sabotageParser struct { - calls int - failOn int - target string - inner Parser - t *testing.T - sabbed bool + calls int + failOn int + target string + inner Parser + t *testing.T + sabbed bool } func (p *sabotageParser) Parse(data []byte) (any, error) { diff --git a/internal/writepath/matrix_test.go b/internal/writepath/matrix_test.go index ca9dc00..0fd4488 100644 --- a/internal/writepath/matrix_test.go +++ b/internal/writepath/matrix_test.go @@ -804,4 +804,3 @@ func TestApplyMatrix(t *testing.T) { }) } } - diff --git a/internal/writepath/plan_test.go b/internal/writepath/plan_test.go index 2ea3ceb..43d132f 100644 --- a/internal/writepath/plan_test.go +++ b/internal/writepath/plan_test.go @@ -220,10 +220,10 @@ func TestFlatten_EscapesDotAndBackslashInKeys(t *testing.T) { t.Fatalf("Flatten err = %v", err) } want := map[string]any{ - `a\.b`: 1, - `a\\b`: 2, - `a\\\.b`: 3, - `plain`: 4, + `a\.b`: 1, + `a\\b`: 2, + `a\\\.b`: 3, + `plain`: 4, `nested.c\.d`: 5, } if !reflect.DeepEqual(got, want) { @@ -266,8 +266,8 @@ func TestFlatten_EmptyMapAtLeaf(t *testing.T) { t.Fatalf("Flatten err = %v", err) } want := map[string]any{ - "present": 1, - "nested.kept": "yes", + "present": 1, + "nested.kept": "yes", } if !reflect.DeepEqual(got, want) { t.Fatalf("Flatten = %+v; want %+v", got, want)