feat(mcp): P0 只读 MCP tool runtime + opsxdb-mcp stdio
- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认) - 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go - 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码) - Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
This commit is contained in:
1 parent
5c17d32647
commit
0b68c8344d
19 files changed
+1989
-79
No files matched your search
@@ -0,0 +1,229 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"opsxdb/server/internal/db"
|
||||
)
|
||||
|
||||
func TestIsReadOnlySQL(t *testing.T) {
|
||||
okCases := []string{
|
||||
"SELECT 1",
|
||||
"SHOW TABLES",
|
||||
"EXPLAIN SELECT * FROM t",
|
||||
"WITH c AS (SELECT 1 AS x) SELECT * FROM c",
|
||||
"PRAGMA table_info(t)",
|
||||
"DESCRIBE t",
|
||||
}
|
||||
for _, sql := range okCases {
|
||||
if ok, reason := IsReadOnlySQL(sql); !ok {
|
||||
t.Fatalf("expected read-only ok for %q: %s", sql, reason)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"DELETE FROM t",
|
||||
"DELETE FROM t WHERE id=1",
|
||||
"DROP TABLE t",
|
||||
"INSERT INTO t VALUES (1)",
|
||||
"UPDATE t SET a=1",
|
||||
"CREATE TABLE t (id INT)",
|
||||
"ALTER TABLE t ADD c INT",
|
||||
"TRUNCATE TABLE t",
|
||||
"WITH c AS (SELECT 1) INSERT INTO t SELECT * FROM c",
|
||||
}
|
||||
for _, sql := range bad {
|
||||
if ok, _ := IsReadOnlySQL(sql); ok {
|
||||
t.Fatalf("expected reject for %q", sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRedisReadOnly(t *testing.T) {
|
||||
if _, err := CheckRedisReadOnly("GET foo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CheckRedisReadOnly("TYPE foo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CheckRedisReadOnly("SCAN 0 MATCH * COUNT 10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, cmd := range []string{"FLUSHDB", "FLUSHALL", "KEYS *", "SET a 1", "DEL a"} {
|
||||
if _, err := CheckRedisReadOnly(cmd); err == nil {
|
||||
t.Fatalf("expected deny for %q", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistEmptyRejects(t *testing.T) {
|
||||
rt := NewRuntime(DefaultPolicy()) // empty whitelist
|
||||
rt.LoadConns = func() ([]SavedConnection, error) {
|
||||
return []SavedConnection{{ID: "c1", Name: "n", Driver: "sqlite"}}, nil
|
||||
}
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Driver: "sqlite"}, nil
|
||||
}
|
||||
var audits []AuditEntry
|
||||
rt.Audit = func(e AuditEntry) { audits = append(audits, e) }
|
||||
|
||||
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1",
|
||||
"sql": "SELECT 1",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "whitelist empty") {
|
||||
t.Fatalf("expected whitelist empty error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDangerousSQLBlocked(t *testing.T) {
|
||||
rt := NewRuntime(Policy{
|
||||
AllowedConnectionIDs: []string{"c1"},
|
||||
Mode: ModeReadOnly,
|
||||
DefaultLimit: 100,
|
||||
MaxLimit: 500,
|
||||
})
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Driver: "mysql", Host: "127.0.0.1", Port: 3306}, nil
|
||||
}
|
||||
called := false
|
||||
rt.ExecSQL = func(ctx context.Context, c db.ConnReq) db.ExecResult {
|
||||
called = true
|
||||
return db.ExecResult{}
|
||||
}
|
||||
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1",
|
||||
"sql": "DROP TABLE users",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected DROP blocked")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("ExecSQL must not be called for dangerous/write SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecretOx1(t *testing.T) {
|
||||
plain := "p@ss词"
|
||||
enc := "ox1." + base64.StdEncoding.EncodeToString([]byte(plain))
|
||||
if got := DecodeSecret(enc); got != plain {
|
||||
t.Fatalf("got %q want %q", got, plain)
|
||||
}
|
||||
if DecodeSecret("plain") != "plain" {
|
||||
t.Fatal("passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteListAndExec(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "t.db")
|
||||
if err := os.WriteFile(dbPath, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
init := db.ExecSQL(context.Background(), db.ConnReq{
|
||||
Driver: "sqlite", Database: dbPath,
|
||||
SQL: "CREATE TABLE person (id INTEGER PRIMARY KEY, name TEXT)",
|
||||
})
|
||||
if init.Error != "" {
|
||||
t.Fatal(init.Error)
|
||||
}
|
||||
_ = db.ExecSQL(context.Background(), db.ConnReq{
|
||||
Driver: "sqlite", Database: dbPath,
|
||||
SQL: "INSERT INTO person (name) VALUES ('a')", ConfirmDanger: true,
|
||||
})
|
||||
|
||||
rt := NewRuntime(Policy{
|
||||
AllowedConnectionIDs: []string{"sqlite1"},
|
||||
Mode: ModeReadOnly,
|
||||
DefaultLimit: 100,
|
||||
MaxLimit: 500,
|
||||
})
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Name: "local", Driver: "sqlite", Database: dbPath}, nil
|
||||
}
|
||||
rt.LoadConns = func() ([]SavedConnection, error) {
|
||||
return []SavedConnection{{ID: "sqlite1", Name: "local", Driver: "sqlite", Database: dbPath}}, nil
|
||||
}
|
||||
|
||||
out, err := rt.Call(context.Background(), ToolListTables, map[string]any{"connection_id": "sqlite1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
tables := m["tables"].([]string)
|
||||
found := false
|
||||
for _, tb := range tables {
|
||||
if tb == "person" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tables=%v", tables)
|
||||
}
|
||||
|
||||
desc, err := rt.Call(context.Background(), ToolDescribeTable, map[string]any{
|
||||
"connection_id": "sqlite1", "table": "person",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = desc
|
||||
|
||||
q, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "sqlite1",
|
||||
"sql": "SELECT name FROM person",
|
||||
"limit": 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qm := q.(map[string]any)
|
||||
if qm["rowCount"].(int) < 1 {
|
||||
t.Fatalf("rowCount=%v", qm["rowCount"])
|
||||
}
|
||||
|
||||
// write rejected
|
||||
if _, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "sqlite1",
|
||||
"sql": "INSERT INTO person (name) VALUES ('b')",
|
||||
}); err == nil {
|
||||
t.Fatal("insert must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConnectionsFiltersWhitelist(t *testing.T) {
|
||||
rt := NewRuntime(Policy{AllowedConnectionIDs: []string{"a"}, Mode: ModeReadOnly, DefaultLimit: 100, MaxLimit: 500})
|
||||
rt.LoadConns = func() ([]SavedConnection, error) {
|
||||
return []SavedConnection{
|
||||
{ID: "a", Name: "A", Driver: "mysql", Host: "h", Port: 3306, Password: "secret"},
|
||||
{ID: "b", Name: "B", Driver: "mysql", Host: "h2", Port: 3306},
|
||||
}, nil
|
||||
}
|
||||
out, err := rt.Call(context.Background(), ToolListConnections, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conns := out.(map[string]any)["connections"].([]ConnectionSummary)
|
||||
if len(conns) != 1 || conns[0].ID != "a" {
|
||||
t.Fatalf("%+v", conns)
|
||||
}
|
||||
// ensure no password field leaked via JSON
|
||||
raw := ResultJSON(out)
|
||||
if strings.Contains(raw, "secret") || strings.Contains(raw, "password") {
|
||||
t.Fatalf("password leaked: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolNamesStable(t *testing.T) {
|
||||
names := ToolNames()
|
||||
if len(names) != 7 {
|
||||
t.Fatalf("want 7 tools, got %d", len(names))
|
||||
}
|
||||
if names[0] != ToolListConnections || names[len(names)-1] != ToolExecuteRedisCommand {
|
||||
t.Fatalf("%v", names)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user