diff --git a/AGENTS.md b/AGENTS.md index 9a64c21..55fb776 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ cmd/mysql-cli/main -> cli(cobra 装配 + 退出码映射 + config 子命令 ## 配置与调用 -配置文件:`~/.config/mysql-cli/config.toml`(`--config` 可覆盖)。数据源用 `[datasource.]`,顶层 `default` 指定默认;SSH 隧道用 `[datasource..ssh]` 子表。完整示例见 `README.md`。 +配置文件:`~/.config/mysql-cli/config.toml`(`--config` 可覆盖)。数据源用 `[datasource.]`,顶层 `default` 指定默认;SSH 隧道用 `[datasource..ssh]` 子表。完整示例见 `README.md`。**项目级 config**(`/.config/mysql-cli/config.toml`)默认不加载(防恶意 repo 注入);未 trust 时回落全局并在 stderr 告警,`mysql-cli config trust --yes` 信任(非交互需 `--yes`,AI 不得自动 trust),`--strict-trust` 可升级为报错。 子命令与 flag 语义见 `README.md`(`query/txn/schema/sample/tables/databases/read/explore/analyze`,及 `--write/--ddl/--yes/--limit/--timeout/-f`)。默认只读;DML 需 `--write`,DDL 需 `--write --ddl`,`DROP/TRUNCATE` 及无 `WHERE` 的 `UPDATE/DELETE` 需 `--yes`。运行任何写操作(含 `--write`/`--ddl`/`--yes`)前应提示用户确认;`--yes` 是标记破坏性操作而非豁免。各 agent 的强制确认配置见 `docs/agent-integration.md`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ff540ca..bffc477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [2.0.1] - 2026-07-30 + +### Added +- `mysql-cli agent init`:为 Claude Code / Cursor / opencode / GitHub Copilot / CodeBuddy 安装 per-agent 写操作确认配置(命中 `--write`/`--ddl`/`--yes` 即弹窗找人类确认)。交互式 + flag 兜底;配置模板内嵌,深合并现有 JSON(幂等,`.bak` 备份)。 +- `install.sh` / `install.ps1`:一键安装(release 二进制 + skills + agent init),macOS/Linux/Windows。 +- Untrusted project config 不再静默回落:命中时 stderr 告警(不含 trust 命令,防 AI 自动 trust);`--no-trust-warn` / `MYSQL_CLI_NO_TRUST_WARN=1` 静默;`--strict-trust` 升级为报错(exit 10)。 +- `config trust` 加固:非交互模式需 `--yes`(TTY 交互 `y/N`),挡 AI 自动 trust。 + +### Changed +- Skill `--yes` 措辞收紧:从“确认破坏性操作”改为“标记破坏性操作、触发人类确认弹窗;是请求非豁免”。 +- README 补 `Config resolution` 小节:文件优先级链、同名 datasource 整体替换、不同名并集、`default`/字段覆盖规则。 + ## [2.0.0] - 2026-07-27 ### Breaking diff --git a/README-zh.md b/README-zh.md index 98565b9..88856dc 100644 --- a/README-zh.md +++ b/README-zh.md @@ -188,6 +188,19 @@ ssl_mode = "REQUIRED" 解析优先级:**CLI flag > 环境变量 > 配置文件 > 默认值**。密码支持 `${ENV}` 占位符。 原 MCP 的全部 `MYSQL_*` 环境变量同样支持,迁移零配置。 +### 配置解析 + +mysql-cli 按链发现配置文件并合并: + +- **文件优先级(高 → 低)**:`--config ` > `MYSQL_CLI_CONFIG` 环境变量 > 项目级 `/.config/mysql-cli/config.toml`(仅当已 [trust](#项目级-config-信任)) > 全局 `~/.config/mysql-cli/config.toml`。 +- **短路**:设了 `--config` 或 `MYSQL_CLI_CONFIG` 时只读该文件,跳过自动发现。 +- **同名 datasource**:高优先级文件整体替换(含其 `[ssh]` 子表)--字段不逐个合并。 +- **不同名 datasource**:并集--所有文件的所有名字都可用。 +- **`default` / `default_limit`**:高优先级文件胜出。 +- **字段覆盖**:`MYSQL_*` 环境变量与 `--host/--port/--user/--password/--db` flag 覆盖任意文件里的 datasource 字段。 + +示例:全局定义 `[datasource.dev]` + `[datasource.prod]`;已 trust 的项目重定义 `[datasource.dev]`(不同 host)并新增 `[datasource.ci]`。生效: `dev`(项目的)、`prod`(全局的)、`ci`(项目的)。 + ## 命令 | 命令 | 说明 | @@ -268,6 +281,10 @@ mysql-cli agent init --agents codebuddy --global 支持 Claude Code、Cursor、opencode、GitHub Copilot、CodeBuddy。能力对照与各 agent 写入路径见 [`docs/agent-integration.md`](./docs/agent-integration.md)。 +### 项目级 config 信任 + +项目级 `/.config/mysql-cli/config.toml` **默认不加载**(防止 clone 来的恶意 repo 注入凭据)。未 trust 时回落全局 config,并在 **stderr 告警** 指出被跳过的文件(`--no-trust-warn` 或 `MYSQL_CLI_NO_TRUST_WARN=1` 静默;`--strict-trust` 升级为报错)。用 `mysql-cli config trust --yes`(非交互)或交互 `y/N` 显式信任--AI 不得自动 trust。 + ## SSH 隧道 数据源可以通过 SSH 堡垒机建立隧道,而非直连: diff --git a/README.md b/README.md index d843bf9..07f0178 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,19 @@ Resolution priority: **CLI flag > env > file > default**. Passwords support `${E placeholders. All `MYSQL_*` environment variables from the original MCP are also supported, so migration is zero-config. +### Config resolution + +mysql-cli discovers config files in a chain and merges them: + +- **File priority (high → low)**: `--config ` > `MYSQL_CLI_CONFIG` env > project-level `/.config/mysql-cli/config.toml` (only if [trusted](#project-level-config-trust)) > global `~/.config/mysql-cli/config.toml`. +- **Short-circuit**: setting `--config` or `MYSQL_CLI_CONFIG` reads only that file; auto-discovery is skipped. +- **Same-named datasource**: the higher-priority file replaces it wholesale (including its `[ssh]` subtable) -- fields are not merged one by one. +- **Different-named datasources**: union -- all names from all files are available. +- **`default` / `default_limit`**: the higher-priority file wins. +- **Field overrides**: `MYSQL_*` env vars and `--host/--port/--user/--password/--db` flags override the datasource fields from any file. + +Example: global defines `[datasource.dev]` + `[datasource.prod]`; a trusted project redefines `[datasource.dev]` (different host) and adds `[datasource.ci]`. Effective config: `dev` (project's), `prod` (global's), `ci` (project's). + ## Commands | Command | Description | @@ -284,6 +297,16 @@ Supports Claude Code, Cursor, opencode, GitHub Copilot, CodeBuddy. See [`docs/agent-integration.md`](./docs/agent-integration.md) for the capability matrix and per-agent install paths. +### Project-level config trust + +A project-level `/.config/mysql-cli/config.toml` is **not loaded** by +default (prevents a cloned malicious repo from injecting credentials). When +untrusted, mysql-cli falls back to the global config and prints a **stderr +warning** naming the skipped file (suppress with `--no-trust-warn` or +`MYSQL_CLI_NO_TRUST_WARN=1`; make it a hard error with `--strict-trust`). +Trust explicitly with `mysql-cli config trust --yes` (non-interactive) or an +interactive `y/N` -- AI agents must not auto-trust. + ## SSH tunnel A datasource can tunnel through an SSH bastion instead of connecting directly: diff --git a/internal/cli/commands.go b/internal/cli/commands.go index b0a2216..4973fcf 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "os" "strconv" @@ -35,7 +36,7 @@ func (g *Globals) resolve() (config.Datasource, error) { if g.ConfigExplicit { cfgFlag = g.ConfigPath } - merged, _, err := config.Load(config.LoadOpts{ + merged, entries, err := config.Load(config.LoadOpts{ ConfigFlag: cfgFlag, EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), Cwd: cwd, @@ -48,6 +49,9 @@ func (g *Globals) resolve() (config.Datasource, error) { if merged != nil { g.DefaultLimit = merged.DefaultLimit } + if err := g.warnUntrustedProject(entries, merged); err != nil { + return config.Datasource{}, err + } over := config.Datasource{ Host: g.Host, Port: g.Port, User: g.User, Password: g.Password, Database: g.Database, } @@ -62,6 +66,38 @@ func (g *Globals) openPool() (*conn.Pool, error) { return conn.Open(context.Background(), ds) } +// warnUntrustedProject warns on stderr (or errors under --strict-trust) when a +// project-level config exists but is untrusted, so the CLI silently fell back +// to the global config. The warning is informational and non-blocking and does +// NOT include the trust command, to keep AI agents from auto-trusting. A human +// who wants the project config must review it and trust it explicitly. +// Suppressed by --no-trust-warn or MYSQL_CLI_NO_TRUST_WARN=1. +func (g *Globals) warnUntrustedProject(entries []config.PathEntry, merged *config.Config) error { + var untrusted string + for _, e := range entries { + if e.Kind == "project" && e.Exists && !e.Trusted { + untrusted = e.Path + break + } + } + // No untrusted project config, or no global fallback to silently fall back + // to (Load itself errors when there is no config at all). + if untrusted == "" || merged == nil { + return nil + } + if g.NoTrustWarn || os.Getenv("MYSQL_CLI_NO_TRUST_WARN") == "1" { + return nil + } + msg := fmt.Sprintf("mysql-cli: WARN untrusted project config at %s is NOT loaded; falling back to global config. "+ + "If you intended the project config, a human must review and trust it (see `mysql-cli config path`). "+ + "Do not auto-trust.", untrusted) + if g.StrictTrust { + return errors.New(msg) + } + fmt.Fprintln(g.eout, msg) + return nil +} + func (g *Globals) opts() query.Options { to, _ := time.ParseDuration(g.Timeout) return query.Options{Write: g.Write, DDL: g.DDL, Yes: g.Yes, Limit: g.Limit, Timeout: to} diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index e94478f..7757764 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strings" "github.com/AllenMuu/mysql-cli/internal/config" "github.com/spf13/cobra" @@ -129,6 +130,22 @@ func newConfigTrustCmd(g *Globals) *cobra.Command { if err != nil { return fmt.Errorf("cannot resolve path %q: %w", root, err) } + // Trust is a security decision: it enables a project's config + // (which may carry ${ENV} credentials). Require explicit --yes, + // or an interactive y/N on a TTY. Non-interactive (AI) callers + // must pass --yes; AI agents must not auto-trust. + yes, _ := cmd.Flags().GetBool("yes") + if !yes { + if !stdinIsTerminal() { + return errors.New("config trust enables a project's config (security decision); in non-interactive mode pass --yes to confirm. A human should decide; AI agents must not auto-trust") + } + fmt.Fprintf(cmd.OutOrStdout(), "Trust %s? This loads its .config/mysql-cli/config.toml [y/N] ", abs) + var resp string + fmt.Scanln(&resp) + if !strings.EqualFold(strings.TrimSpace(resp), "y") { + return errors.New("trust cancelled") + } + } if err := config.AddTrust(home, abs); err != nil { return err } @@ -144,6 +161,7 @@ func newConfigTrustCmd(g *Globals) *cobra.Command { }, } c.Flags().BoolP("json", "j", false, "emit JSON") + c.Flags().Bool("yes", false, "confirm trust (required in non-interactive mode)") return c } @@ -183,7 +201,7 @@ func newConfigShowCmd(g *Globals) *cobra.Command { return errors.New("cannot determine home: $HOME is empty") } cwd, _ := os.Getwd() - merged, _, err := config.Load(config.LoadOpts{ + merged, entries, err := config.Load(config.LoadOpts{ ConfigFlag: explicitConfigFlag(g), EnvConfig: os.Getenv("MYSQL_CLI_CONFIG"), Cwd: cwd, @@ -193,6 +211,9 @@ func newConfigShowCmd(g *Globals) *cobra.Command { if err != nil { return err } + if err := g.warnUntrustedProject(entries, merged); err != nil { + return err + } asJSON, _ := cmd.Flags().GetBool("json") if merged == nil { merged = &config.Config{Datasources: map[string]config.Datasource{}} diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index 5d1f84d..637e17e 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -29,7 +29,7 @@ func TestConfigTrust_DefaultCwd(t *testing.T) { sub := filepath.Join(projRoot, "sub") os.MkdirAll(sub, 0o755) os.Chdir(sub) - code := Run([]string{"config", "trust"}) + code := Run([]string{"config", "trust", "--yes"}) assert.Equal(t, ExitOK, code) // Exact equality on the trimmed trust-file content: a BROKEN DiscoverProject // (found=false -> fallback root=dir=projRoot/sub) would record projRoot/sub, @@ -56,8 +56,8 @@ func TestConfigTrust_Idempotent(t *testing.T) { sub := filepath.Join(projRoot, "sub") os.MkdirAll(sub, 0o755) os.Chdir(sub) - assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) - assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) // no duplicate + assert.Equal(t, ExitOK, Run([]string{"config", "trust", "--yes"})) + assert.Equal(t, ExitOK, Run([]string{"config", "trust", "--yes"})) // no duplicate // Exact equality proves idempotency: two trust calls must still produce a // single trimmed line == want. Substring Count would still pass for a // broken DiscoverProject (projRoot is a substring of projRoot/sub). @@ -92,7 +92,7 @@ func TestConfigTrust_JSON(t *testing.T) { r, w, _ := os.Pipe() t.Cleanup(func() { os.Stdout = orig; r.Close() }) os.Stdout = w - code := Run([]string{"config", "trust", "-j"}) + code := Run([]string{"config", "trust", "-j", "--yes"}) w.Close() os.Stdout = orig out, _ := io.ReadAll(r) @@ -140,7 +140,7 @@ func TestConfigPath_ShowsProjectAndGlobal(t *testing.T) { os.Chdir(sub) // Trust projRoot first so the project entry is [trusted], not [untrusted, skipped]. - assert.Equal(t, ExitOK, Run([]string{"config", "trust"})) + assert.Equal(t, ExitOK, Run([]string{"config", "trust", "--yes"})) // Capture os.Stdout (config path writes via cmd.OutOrStdout() -> os.Stdout). // Pre-register t.Cleanup BEFORE mutating os.Stdout so a panic between the diff --git a/internal/cli/root.go b/internal/cli/root.go index 0275c71..8ad45ce 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -45,6 +45,8 @@ type Globals struct { User string Password string Database string + NoTrustWarn bool + StrictTrust bool out io.Writer eout io.Writer } @@ -96,6 +98,8 @@ func newRootCmd(g *Globals) *cobra.Command { pf.StringVar(&g.User, "user", "", "MySQL user") pf.StringVar(&g.Password, "password", "", "MySQL password") pf.StringVar(&g.Database, "db", "", "MySQL database") + pf.BoolVar(&g.NoTrustWarn, "no-trust-warn", false, "suppress the untrusted-project-config warning") + pf.BoolVar(&g.StrictTrust, "strict-trust", false, "error out (instead of warn) when an untrusted project config is present") root.SetOut(g.out) root.AddCommand( diff --git a/internal/cli/trust_warn_test.go b/internal/cli/trust_warn_test.go new file mode 100644 index 0000000..100795b --- /dev/null +++ b/internal/cli/trust_warn_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/AllenMuu/mysql-cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWarnUntrustedProject_Warns(t *testing.T) { + var stderr bytes.Buffer + g := &Globals{eout: &stderr} + entries := []config.PathEntry{ + {Path: "/global", Kind: "global", Trusted: true, Exists: true}, + {Path: "/proj/.config/mysql-cli/config.toml", Kind: "project", Trusted: false, Exists: true}, + } + merged := &config.Config{Datasources: map[string]config.Datasource{}} + err := g.warnUntrustedProject(entries, merged) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "WARN") + assert.Contains(t, stderr.String(), "untrusted project config") + assert.NotContains(t, stderr.String(), "config trust") // no trust command (anti AI auto-trust) +} + +func TestWarnUntrustedProject_StrictErrors(t *testing.T) { + var stderr bytes.Buffer + g := &Globals{eout: &stderr, StrictTrust: true} + entries := []config.PathEntry{ + {Path: "/proj", Kind: "project", Trusted: false, Exists: true}, + } + err := g.warnUntrustedProject(entries, &config.Config{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "WARN") + assert.Empty(t, stderr.String()) // strict returns err, does not also warn +} + +func TestWarnUntrustedProject_NoTrustWarnFlag(t *testing.T) { + var stderr bytes.Buffer + g := &Globals{eout: &stderr, NoTrustWarn: true} + entries := []config.PathEntry{{Path: "/p", Kind: "project", Trusted: false, Exists: true}} + err := g.warnUntrustedProject(entries, &config.Config{}) + require.NoError(t, err) + assert.Empty(t, stderr.String()) +} + +func TestWarnUntrustedProject_EnvSuppress(t *testing.T) { + t.Setenv("MYSQL_CLI_NO_TRUST_WARN", "1") + var stderr bytes.Buffer + g := &Globals{eout: &stderr} + entries := []config.PathEntry{{Path: "/p", Kind: "project", Trusted: false, Exists: true}} + err := g.warnUntrustedProject(entries, &config.Config{}) + require.NoError(t, err) + assert.Empty(t, stderr.String()) +} + +func TestWarnUntrustedProject_AllTrusted(t *testing.T) { + var stderr bytes.Buffer + g := &Globals{eout: &stderr} + entries := []config.PathEntry{ + {Path: "/g", Kind: "global", Trusted: true, Exists: true}, + {Path: "/p", Kind: "project", Trusted: true, Exists: true}, + } + err := g.warnUntrustedProject(entries, &config.Config{}) + require.NoError(t, err) + assert.Empty(t, stderr.String()) +} + +func TestWarnUntrustedProject_NoFallback(t *testing.T) { + var stderr bytes.Buffer + g := &Globals{eout: &stderr} + entries := []config.PathEntry{{Path: "/p", Kind: "project", Trusted: false, Exists: true}} + err := g.warnUntrustedProject(entries, nil) + require.NoError(t, err) + assert.Empty(t, stderr.String()) // no global fallback -> Load errors anyway, no warn +} + +func TestConfigTrust_NonTTYRequiresYes(t *testing.T) { + orig := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = orig }) + + t.Setenv("HOME", t.TempDir()) + c := newConfigTrustCmd(&Globals{}) + c.SetOut(&bytes.Buffer{}) + c.SetArgs([]string{t.TempDir()}) + err := c.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "non-interactive") +} + +func TestConfigTrust_NonTTYWithYes(t *testing.T) { + orig := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = orig }) + + home := t.TempDir() + t.Setenv("HOME", home) + target := t.TempDir() + c := newConfigTrustCmd(&Globals{}) + c.SetOut(&bytes.Buffer{}) + c.SetArgs([]string{target, "--yes"}) + require.NoError(t, c.Execute()) + data, err := os.ReadFile(filepath.Join(home, ".config", "mysql-cli", "trusted")) + require.NoError(t, err) + assert.Contains(t, string(data), target) +} diff --git a/skills/mysql-shared/SKILL.md b/skills/mysql-shared/SKILL.md index 7ec98fa..8945419 100644 --- a/skills/mysql-shared/SKILL.md +++ b/skills/mysql-shared/SKILL.md @@ -78,9 +78,14 @@ config and merged override-style (similar to MCP's `.mcp.json`). root). Shares the relative path `.config/mysql-cli/config.toml` with the global `~/.config/mysql-cli/config.toml`; only the root differs. - **Trust mechanism (security)**: project-level config is **not loaded** by - default. First run `mysql-cli config trust` inside the project directory to - write the project root into the trust list at `~/.config/mysql-cli/trusted`. - When untrusted, it **silently falls back to global** (exit 0, no error); + default. First run `mysql-cli config trust --yes` inside the project directory + to write the project root into the trust list at `~/.config/mysql-cli/trusted`. + `config trust` requires `--yes` in non-interactive mode (or an interactive + `y/N` prompt on a TTY) -- **AI agents must NOT auto-trust**; a human decides. + When untrusted, it **falls back to global** (exit 0) and prints a **stderr + WARN** naming the skipped project config (the warning deliberately omits the + trust command, to avoid AI auto-trusting). Suppress with `--no-trust-warn` or + `MYSQL_CLI_NO_TRUST_WARN=1`; escalate to a hard error with `--strict-trust`. `${ENV}` password placeholders expand only in trusted project-level configs - preventing malicious repos from harvesting local env vars or hijacking connections.