同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库: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:
1 parent
5c0be49217
commit
2c0bfafd8d
269 files changed
+10372
-16435
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user