同步完整源码至远程:扁平化重构现状 + 未入库源文件

- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、
  packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件
- 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源)
- 构建产物(bin/target/dist)与签名私钥仍按规则排除
This commit is contained in:
logan committed 2026-10-16 03:47:33 +08:00
1 parent 5c0be49217
commit 2c0bfafd8d
269 files changed
+10372 -16435

No files matched your search

+12
View File
@@ -15,6 +15,18 @@ func ConfirmBindHash(connectionID, statement string) string {
return hex.EncodeToString(sum[:])
}
// Agent 写操作还绑定数据库,避免确认时切换到同连接的其他库。
func ConfirmAgentBindHash(connectionID, database, statement string) string {
return ConfirmBindHash(connectionID, strings.TrimSpace(database)+"\n"+statement)
}
func VerifyAgentBind(connectionID, database, statement, bind string) error {
if strings.TrimSpace(bind) == "" || !strings.EqualFold(bind, ConfirmAgentBindHash(connectionID, database, statement)) {
return fmt.Errorf("confirmBind mismatch: statement, connection or database changed; re-confirm required")
}
return nil
}
// VerifyConfirmBind 校验客户端带回的 bind;空 bind 视为不合法(桌面路径必须带)。
func VerifyConfirmBind(connectionID, statement, bind string) error {
bind = strings.TrimSpace(bind)
@@ -23,6 +23,19 @@ func TestConfirmBindHashStableAndSensitive(t *testing.T) {
}
}
func TestAgentConfirmBindIncludesDatabase(t *testing.T) {
bind := ConfirmAgentBindHash("c1", "demo", "DELETE FROM users WHERE id=1")
if err := VerifyAgentBind("c1", "demo", "DELETE FROM users WHERE id=1", bind); err != nil {
t.Fatal(err)
}
if err := VerifyAgentBind("c1", "other", "DELETE FROM users WHERE id=1", bind); err == nil {
t.Fatal("database switch must invalidate confirmation")
}
if err := VerifyAgentBind("c2", "demo", "DELETE FROM users WHERE id=1", bind); err == nil {
t.Fatal("connection switch must invalidate confirmation")
}
}
func TestNeedConfirmIncludesBind(t *testing.T) {
rt := NewRuntime(Policy{
Mode: ModeReadWrite,
@@ -55,3 +68,23 @@ func TestNeedConfirmIncludesBind(t *testing.T) {
t.Fatalf("bind %s want %s", bind, want)
}
}
func TestAgentWriteProposalBindsCurrentDatabase(t *testing.T) {
rt := NewRuntime(Policy{Mode: ModeReadWrite, AllowedConnectionIDs: []string{"c1"}})
rt.BoundConnectionID = "c1"
rt.BoundDatabase = "demo"
rt.RejectCallerConfirm = true
rt.Audit = func(AuditEntry) {}
statement := "DELETE FROM users WHERE id=1"
out, err := rt.Call(nil, ToolExecuteQuery, map[string]any{"connection_id": "c1", "sql": statement, "database": "demo"})
if err != nil {
t.Fatal(err)
}
bind := out.(map[string]any)["confirmBind"].(string)
if err := VerifyAgentBind("c1", "demo", statement, bind); err != nil {
t.Fatal(err)
}
if err := VerifyAgentBind("c1", "other", statement, bind); err == nil {
t.Fatal("confirmation must be scoped to demo")
}
}
+13 -1
View File
@@ -92,7 +92,19 @@ func SaveConnections(list []SavedConnection) error {
if err != nil {
return err
}
return os.WriteFile(path, b, 0600)
f, err := os.CreateTemp(filepath.Dir(path), ".connections-*.json")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.Write(b); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), path)
}
func FindConnection(id string) (*SavedConnection, error) {
+8 -14
View File
@@ -1,6 +1,7 @@
package aitools
import (
"context"
"encoding/json"
"net/http"
"os"
@@ -101,7 +102,9 @@ func findToolVersion(name string, versionArg string) (bin, version string) {
if bin == "" {
return "", ""
}
out, err := exec.Command(bin, versionArg).CombinedOutput()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, bin, versionArg).CombinedOutput()
if err != nil {
return bin, ""
}
@@ -130,14 +133,7 @@ func lookPathAugmented(name string) string {
"/usr/local/bin",
)
}
pathEnv := os.Getenv("PATH")
aug := strings.Join(extras, string(os.PathListSeparator))
if pathEnv != "" {
aug = aug + string(os.PathListSeparator) + pathEnv
}
cmd := exec.Command(name, "--version")
cmd.Env = append(os.Environ(), "PATH="+aug)
// Prefer LookPath with augmented PATH
// 检查常见安装路径,不执行探测命令。
for _, dir := range extras {
cand := filepath.Join(dir, name)
if runtime.GOOS == "windows" {
@@ -147,15 +143,13 @@ func lookPathAugmented(name string) string {
return cand
}
}
if p, err := exec.LookPath(name); err == nil {
return p
}
_ = cmd
return ""
}
func npmGlobalPackageVersion(npmBin, pkg string) string {
out, err := exec.Command(npmBin, "list", "-g", pkg, "--depth=0", "--json").CombinedOutput()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, npmBin, "list", "-g", pkg, "--depth=0", "--json").CombinedOutput()
if err != nil && len(out) == 0 {
return ""
}
+8 -5
View File
@@ -8,10 +8,10 @@ import (
"strings"
)
// Mode P0 仅支持 read_only;read_write 留给 P1(写 + 确认)。 // Logan.Li
// Mode 控制读写权限;写操作仍须确认。
const (
ModeReadOnly = "read_only"
ModeReadWrite = "read_write" // P1 才启用写路径;P0 加载后仍强制只读执行
ModeReadWrite = "read_write"
)
// Policy ~/.opsxdb/mcp.json
@@ -19,8 +19,7 @@ type Policy struct {
AllowedConnectionIDs []string `json:"allowed_connection_ids"`
Mode string `json:"mode"` // read_only | read_write
// AllowDangerous 为 true 时,在 mode=read_write 且已确认后允许 DROP/TRUNCATE/ALTER 等危险 SQL。
// 默认 false:普通 INSERT/UPDATE/DELETE(有 WHERE)仍可写,DDL/无 WHERE 等仍拒。
AllowDangerous bool `json:"allow_dangerous,omitempty"`
AllowDangerous bool `json:"allow_dangerous"`
DefaultLimit int `json:"default_limit,omitempty"`
MaxLimit int `json:"max_limit,omitempty"`
}
@@ -28,7 +27,8 @@ type Policy struct {
func DefaultPolicy() Policy {
return Policy{
AllowedConnectionIDs: nil,
Mode: ModeReadOnly,
Mode: ModeReadWrite,
AllowDangerous: true,
DefaultLimit: 100,
MaxLimit: 500,
}
@@ -64,6 +64,9 @@ func LoadPolicy() (Policy, error) {
}
return p, err
}
// 已有文件沿用显式配置;旧版缺少危险 SQL 字段时仍视为禁用。
p.Mode = ModeReadOnly
p.AllowDangerous = false
if err := json.Unmarshal(b, &p); err != nil {
return DefaultPolicy(), fmt.Errorf("mcp.json: %w", err)
}
+26
View File
@@ -65,4 +65,30 @@ func TestDangerousSQLRequiresAllowFlag(t *testing.T) {
}
}
func TestDefaultWritePolicyStillRequiresConfirmation(t *testing.T) {
p := DefaultPolicy()
if !p.WritesEnabled() || !p.AllowsDangerousDDL() {
t.Fatal("新配置应默认允许读写和危险 SQL")
}
if err := p.AllowConnection("c1"); err == nil {
t.Fatal("默认权限不能自动授权连接")
}
p.AllowedConnectionIDs = []string{"c1"}
rt := NewRuntime(p)
rt.RejectCallerConfirm = true
rt.Audit = func(AuditEntry) {}
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Driver: "mysql"}, nil
}
out, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1", "sql": "DROP TABLE demo", "confirm_danger": true,
})
if err != nil {
t.Fatal(err)
}
if out.(map[string]any)["needConfirm"] != true {
t.Fatalf("默认允许危险 SQL 后,Agent 仍必须等待用户确认: %v", out)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
+53 -11
View File
@@ -37,6 +37,9 @@ func ToolNames() []string {
// Runtime 可被 MCP stdio 与将来侧栏 Agent 进程内共用。 // Logan.Li
type Runtime struct {
Policy Policy
// Agent 会话限定到当前编辑器上下文;MCP 外部调用保持独立的白名单策略。
BoundConnectionID string
BoundDatabase string
// LoadConns / FindConn 可注入,便于单测;nil 则读磁盘。
LoadConns func() ([]SavedConnection, error)
FindConn func(id string) (*SavedConnection, error)
@@ -144,6 +147,31 @@ func (r *Runtime) listCol(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, e
// Call 统一入口:MCP 与 Agent 都走这里。args 为 JSON object。
func (r *Runtime) Call(ctx context.Context, tool string, args map[string]any) (any, error) {
if r.BoundConnectionID != "" {
if tool == ToolListConnections {
return r.listBoundConnection(ctx)
}
if argString(args, "connection_id") != r.BoundConnectionID {
return nil, fmt.Errorf("tool connection differs from the current Agent connection")
}
if tool == ToolListDatabases && r.BoundDatabase != "" {
if _, err := r.resolveAllowed(r.BoundConnectionID); err != nil {
return nil, err
}
return map[string]any{"databases": []string{r.BoundDatabase}}, nil
}
if database := argString(args, "database"); database != "" && r.BoundDatabase != "" && database != r.BoundDatabase {
return nil, fmt.Errorf("tool database differs from the current Agent database")
}
if r.BoundDatabase != "" {
clone := make(map[string]any, len(args)+1)
for k, v := range args {
clone[k] = v
}
clone["database"] = r.BoundDatabase
args = clone
}
}
switch tool {
case ToolListConnections:
return r.listConnections(ctx)
@@ -164,6 +192,15 @@ func (r *Runtime) Call(ctx context.Context, tool string, args map[string]any) (a
}
}
func (r *Runtime) listBoundConnection(ctx context.Context) (any, error) {
_ = ctx
c, err := r.resolveAllowed(r.BoundConnectionID)
if err != nil {
return nil, err
}
return map[string]any{"connections": []ConnectionSummary{c.Summary()}}, nil
}
func (r *Runtime) listConnections(ctx context.Context) (any, error) {
_ = ctx
list, err := r.loadConns()
@@ -250,7 +287,6 @@ func argBool(args map[string]any, key string) bool {
}
}
func (r *Runtime) listDatabases(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
c, err := r.resolveAllowed(id)
@@ -409,12 +445,15 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
if !confirmed {
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
bind := ConfirmBindHash(id, sqlText)
if r.BoundConnectionID != "" {
bind = ConfirmAgentBindHash(id, database, sqlText)
}
return map[string]any{
"needConfirm": true,
"sql": sqlText,
"confirmBind": bind,
"message": "write requires explicit user confirmation",
"reason": readReason,
"needConfirm": true,
"sql": sqlText,
"confirmBind": bind,
"message": "write requires explicit user confirmation",
"reason": readReason,
}, nil
}
}
@@ -483,12 +522,15 @@ func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, e
if !confirmed {
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(command, 80)})
bind := ConfirmBindHash(id, command)
if r.BoundConnectionID != "" {
bind = ConfirmAgentBindHash(id, database, command)
}
return map[string]any{
"needConfirm": true,
"command": command,
"confirmBind": bind,
"message": "redis write requires explicit user confirmation",
"reason": rerr.Error(),
"needConfirm": true,
"command": command,
"confirmBind": bind,
"message": "redis write requires explicit user confirmation",
"reason": rerr.Error(),
}, nil
}
}
+37
View File
@@ -80,6 +80,43 @@ func TestWhitelistEmptyRejects(t *testing.T) {
}
}
func TestAgentBoundToolsStayOnCurrentConnectionAndDatabase(t *testing.T) {
rt := NewRuntime(Policy{AllowedConnectionIDs: []string{"current", "other"}, Mode: ModeReadOnly})
rt.BoundConnectionID = "current"
rt.BoundDatabase = "demo"
rt.Audit = func(AuditEntry) {}
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Name: id, Driver: "mysql", Database: "default"}, nil
}
rt.LoadConns = func() ([]SavedConnection, error) {
return []SavedConnection{{ID: "current"}, {ID: "other"}}, nil
}
var actualDatabase string
rt.ListTbl = func(_ context.Context, req db.ConnReq) ([]string, error) {
actualDatabase = req.Database
return []string{"users"}, nil
}
res, err := rt.Call(context.Background(), ToolListConnections, nil)
if err != nil {
t.Fatal(err)
}
if got := res.(map[string]any)["connections"].([]ConnectionSummary); len(got) != 1 || got[0].ID != "current" {
t.Fatalf("Agent should only see the bound connection: %+v", got)
}
for _, args := range []map[string]any{
{"connection_id": "other", "database": "demo"},
{"connection_id": "current", "database": "secret"},
} {
if _, err := rt.Call(context.Background(), ToolListTables, args); err == nil {
t.Fatalf("out-of-scope tool call was allowed: %+v", args)
}
}
_, err = rt.Call(context.Background(), ToolListTables, map[string]any{"connection_id": "current"})
if err != nil || actualDatabase != "demo" {
t.Fatalf("current database was not applied: database=%q err=%v", actualDatabase, err)
}
}
func TestDangerousSQLBlocked(t *testing.T) {
rt := NewRuntime(Policy{
AllowedConnectionIDs: []string{"c1"},