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:
logan committed 2026-10-15 11:14:08 +08:00
1 parent 5c17d32647
commit 0b68c8344d
19 files changed
+1989 -79

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
package aitools
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// AuditEntry MCP/Agent 工具审计;不含密码与大结果全文。 // Logan.Li
type AuditEntry struct {
Time string `json:"time"`
Tool string `json:"tool"`
ConnectionID string `json:"connection_id,omitempty"`
Mode string `json:"mode"`
OK bool `json:"ok"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
}
var auditMu sync.Mutex
func AuditPath() (string, error) {
dir, err := opsxdbDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "logs", "mcp-audit.jsonl"), nil
}
// AppendAudit 追加一行 JSONL;失败静默(不影响工具主路径)。
func AppendAudit(e AuditEntry) {
if e.Time == "" {
e.Time = time.Now().Format(time.RFC3339)
}
path, err := AuditPath()
if err != nil {
return
}
auditMu.Lock()
defer auditMu.Unlock()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return
}
b, err := json.Marshal(e)
if err != nil {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return
}
defer f.Close()
_, _ = f.Write(append(b, '\n'))
}
+167
View File
@@ -0,0 +1,167 @@
package aitools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"opsxdb/server/internal/db"
)
// SavedConnection 与桌面 store SavedConnection JSON 对齐(磁盘形态,密码可为 ox1. 混淆)。 // Logan.Li
type SavedConnection struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
Service string `json:"service,omitempty"`
SSL bool `json:"ssl,omitempty"`
SSLMode string `json:"sslMode,omitempty"`
SSLCA string `json:"sslCA,omitempty"`
SSLCert string `json:"sslCert,omitempty"`
SSLKey string `json:"sslKey,omitempty"`
RedisMode string `json:"redisMode,omitempty"`
RedisNodes string `json:"redisNodes,omitempty"`
MasterName string `json:"masterName,omitempty"`
SentinelPassword string `json:"sentinelPassword,omitempty"`
SSHEnabled bool `json:"sshEnabled,omitempty"`
SSHHost string `json:"sshHost,omitempty"`
SSHPort int `json:"sshPort,omitempty"`
SSHUser string `json:"sshUser,omitempty"`
SSHAuth string `json:"sshAuth,omitempty"`
SSHPassword string `json:"sshPassword,omitempty"`
SSHPrivateKey string `json:"sshPrivateKey,omitempty"`
SSHPassphrase string `json:"sshPassphrase,omitempty"`
SavePassword *bool `json:"savePassword,omitempty"`
}
// ConnectionSummary 列表对外字段,永不含密码。
type ConnectionSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
}
func ConnectionsPath() (string, error) {
dir, err := opsxdbDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "connections.json"), nil
}
// LoadConnections 读 ~/.opsxdb/connections.json。
func LoadConnections() ([]SavedConnection, error) {
path, err := ConnectionsPath()
if err != nil {
return nil, err
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var list []SavedConnection
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("connections.json: %w", err)
}
return list, nil
}
// SaveConnections 写 ~/.opsxdb/connections.json(0600);供桌面双写 API 调用。
func SaveConnections(list []SavedConnection) error {
path, err := ConnectionsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0600)
}
func FindConnection(id string) (*SavedConnection, error) {
list, err := LoadConnections()
if err != nil {
return nil, err
}
for i := range list {
if list[i].ID == id {
c := list[i]
return &c, nil
}
}
return nil, fmt.Errorf("connection not found: %s", id)
}
// ToConnReq 映射为 db.ConnReq(解码 ox1. 密码)。
func (c SavedConnection) ToConnReq() db.ConnReq {
sslOn := c.SSL || (c.SSLMode != "" && c.SSLMode != "disable")
mode := c.SSLMode
if sslOn {
if mode == "" || mode == "disable" {
mode = "require"
}
} else {
mode = "disable"
}
return db.ConnReq{
Driver: c.Driver,
Host: c.Host,
Port: c.Port,
User: c.User,
Password: DecodeSecret(c.Password),
Database: c.Database,
Service: c.Service,
SSL: sslOn,
SSLMode: mode,
SSLCA: c.SSLCA,
SSLCert: c.SSLCert,
SSLKey: c.SSLKey,
RedisMode: orDefault(c.RedisMode, "standalone"),
RedisNodes: c.RedisNodes,
MasterName: c.MasterName,
SentinelPassword: DecodeSecret(c.SentinelPassword),
SSHEnabled: c.SSHEnabled,
SSHHost: c.SSHHost,
SSHPort: c.SSHPort,
SSHUser: c.SSHUser,
SSHAuth: c.SSHAuth,
SSHPassword: DecodeSecret(c.SSHPassword),
SSHPrivateKey: c.SSHPrivateKey,
SSHPassphrase: DecodeSecret(c.SSHPassphrase),
}
}
func (c SavedConnection) Summary() ConnectionSummary {
return ConnectionSummary{
ID: c.ID,
Name: c.Name,
Driver: c.Driver,
Host: c.Host,
Port: c.Port,
Database: c.Database,
}
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
+112
View File
@@ -0,0 +1,112 @@
package aitools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// Mode P0 仅支持 read_only;read_write 留给 P1(写 + 确认)。 // Logan.Li
const (
ModeReadOnly = "read_only"
ModeReadWrite = "read_write" // P1 才启用写路径;P0 加载后仍强制只读执行
)
// Policy ~/.opsxdb/mcp.json
type Policy struct {
AllowedConnectionIDs []string `json:"allowed_connection_ids"`
Mode string `json:"mode"` // read_only | read_write
DefaultLimit int `json:"default_limit,omitempty"`
MaxLimit int `json:"max_limit,omitempty"`
}
func DefaultPolicy() Policy {
return Policy{
AllowedConnectionIDs: nil,
Mode: ModeReadOnly,
DefaultLimit: 100,
MaxLimit: 500,
}
}
func opsxdbDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".opsxdb"), nil
}
func PolicyPath() (string, error) {
dir, err := opsxdbDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "mcp.json"), nil
}
// LoadPolicy 读 mcp.json;缺省文件返回 DefaultPolicy(白名单空=拒绝全部连接)。
func LoadPolicy() (Policy, error) {
p := DefaultPolicy()
path, err := PolicyPath()
if err != nil {
return p, err
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return p, nil
}
return p, err
}
if err := json.Unmarshal(b, &p); err != nil {
return DefaultPolicy(), fmt.Errorf("mcp.json: %w", err)
}
p.Mode = strings.ToLower(strings.TrimSpace(p.Mode))
if p.Mode == "" {
p.Mode = ModeReadOnly
}
if p.DefaultLimit <= 0 {
p.DefaultLimit = 100
}
if p.MaxLimit <= 0 {
p.MaxLimit = 500
}
if p.DefaultLimit > p.MaxLimit {
p.DefaultLimit = p.MaxLimit
}
return p, nil
}
func (p Policy) IsReadOnly() bool {
// P0:即使配置写了 read_write,执行层仍按只读拦截;此 helper 表示「策略意图」。
return p.Mode != ModeReadWrite
}
func (p Policy) AllowConnection(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("connection_id required")
}
if len(p.AllowedConnectionIDs) == 0 {
return fmt.Errorf("connection not allowed: whitelist empty (configure allowed_connection_ids in ~/.opsxdb/mcp.json)")
}
for _, a := range p.AllowedConnectionIDs {
if a == id {
return nil
}
}
return fmt.Errorf("connection not allowed: %s not in allowed_connection_ids", id)
}
func (p Policy) ClampLimit(n int) int {
if n <= 0 {
n = p.DefaultLimit
}
if n > p.MaxLimit {
n = p.MaxLimit
}
return n
}
+119
View File
@@ -0,0 +1,119 @@
package aitools
import (
"fmt"
"strings"
"unicode"
"opsxdb/server/internal/db"
)
// redisReadCmds P0 纯只读允许的 Redis 命令(对齐现有 execRedis 读路径)。 // Logan.Li
var redisReadCmds = map[string]bool{
"PING": true, "GET": true, "MGET": true, "TYPE": true, "TTL": true, "PTTL": true,
"EXISTS": true, "SCAN": true, "HGET": true, "HGETALL": true, "HLEN": true, "HKEYS": true, "HVALS": true,
"LRANGE": true, "LLEN": true, "LINDEX": true,
"SMEMBERS": true, "SCARD": true, "SISMEMBER": true,
"ZRANGE": true, "ZCARD": true, "ZSCORE": true, "ZRANK": true,
"STRLEN": true, "DBSIZE": true, "INFO": true, "OBJECT": true, "MEMORY": true,
}
// redisDeniedAlways P0 明确拒绝(即便将来 read_write 也需确认;此处直接拒)。
var redisDeniedAlways = map[string]bool{
"FLUSHALL": true, "FLUSHDB": true, "KEYS": true, "CONFIG": true,
}
// IsReadOnlySQL P0:仅允许读类 SQL。INSERT/UPDATE/DELETE/DDL 一律 false。
// WITH 仅当体中无写动词且含 SELECT 时放行。复用 CheckDangerousSQL 作二次拦截。
func IsReadOnlySQL(sqlText string) (bool, string) {
s := strings.TrimSpace(stripLeadComments(sqlText))
if s == "" {
return false, "empty sql"
}
up := strings.ToUpper(s)
// 去掉开头空白后的首词
first := firstWord(up)
switch first {
case "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA":
// ok
case "WITH":
// WITH … SELECT 可读;WITH … INSERT/UPDATE/DELETE 拒
if hasWriteVerb(up) {
return false, "WITH statement contains write verb (P0 read-only)"
}
if !strings.Contains(up, "SELECT") {
return false, "WITH without SELECT is not allowed in read-only mode"
}
default:
return false, fmt.Sprintf("not a read-only statement (starts with %s); P0 MCP is read-only", first)
}
if hits := db.CheckDangerousSQL("", sqlText); len(hits) > 0 {
return false, "dangerous sql blocked: " + strings.Join(hits, "; ")
}
return true, ""
}
func hasWriteVerb(up string) bool {
for _, v := range []string{" INSERT ", " UPDATE ", " DELETE ", " DROP ", " ALTER ", " CREATE ", " TRUNCATE ", " REPLACE ", " MERGE ", " GRANT ", " REVOKE ", " CALL ", " EXEC ", " EXECUTE "} {
if strings.Contains(" "+up+" ", v) {
return true
}
}
// 行首写动词
for _, v := range []string{"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "REPLACE", "MERGE"} {
if strings.HasPrefix(up, v+" ") || up == v {
return true
}
}
return false
}
func firstWord(up string) string {
up = strings.TrimSpace(up)
i := 0
for i < len(up) && (unicode.IsLetter(rune(up[i])) || up[i] == '_') {
i++
}
if i == 0 {
return ""
}
return up[:i]
}
func stripLeadComments(sql string) string {
s := strings.TrimSpace(sql)
for {
if strings.HasPrefix(s, "--") {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = strings.TrimSpace(s[i+1:])
continue
}
return ""
}
if strings.HasPrefix(s, "/*") {
if i := strings.Index(s, "*/"); i >= 0 {
s = strings.TrimSpace(s[i+2:])
continue
}
return ""
}
break
}
return s
}
// CheckRedisReadOnly P0:仅白名单读命令;FLUSH*/KEYS/CONFIG 等一律拒。
func CheckRedisReadOnly(commandLine string) (cmd string, err error) {
fields := strings.Fields(strings.TrimSpace(commandLine))
if len(fields) == 0 {
return "", fmt.Errorf("empty redis command")
}
cmd = strings.ToUpper(fields[0])
if redisDeniedAlways[cmd] {
return cmd, fmt.Errorf("redis command denied in P0 read-only: %s", cmd)
}
if !redisReadCmds[cmd] {
return cmd, fmt.Errorf("redis command not allowed in P0 read-only: %s (writes require P1)", cmd)
}
return cmd, nil
}
+466
View File
@@ -0,0 +1,466 @@
package aitools
import (
"context"
"encoding/json"
"fmt"
"strings"
"opsxdb/server/internal/db"
)
const (
ToolListConnections = "opsxdb_list_connections"
ToolListDatabases = "opsxdb_list_databases"
ToolListTables = "opsxdb_list_tables"
ToolDescribeTable = "opsxdb_describe_table"
ToolGetSchemaContext = "opsxdb_get_schema_context"
ToolExecuteQuery = "opsxdb_execute_query"
ToolExecuteRedisCommand = "opsxdb_execute_redis_command"
schemaContextCap = 4000
)
// ToolNames P0 注册表(顺序稳定,便于 tools/list 断言)。
func ToolNames() []string {
return []string{
ToolListConnections,
ToolListDatabases,
ToolListTables,
ToolDescribeTable,
ToolGetSchemaContext,
ToolExecuteQuery,
ToolExecuteRedisCommand,
}
}
// Runtime 可被 MCP stdio 与将来侧栏 Agent 进程内共用。 // Logan.Li
type Runtime struct {
Policy Policy
// LoadConns / FindConn 可注入,便于单测;nil 则读磁盘。
LoadConns func() ([]SavedConnection, error)
FindConn func(id string) (*SavedConnection, error)
// ExecSQL 可注入;nil 则 db.ExecSQL。
ExecSQL func(ctx context.Context, c db.ConnReq) db.ExecResult
ListDB func(ctx context.Context, c db.ConnReq) ([]string, error)
ListTbl func(ctx context.Context, c db.ConnReq) ([]string, error)
ListCol func(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, error)
Audit func(AuditEntry)
}
func NewRuntime(policy Policy) *Runtime {
return &Runtime{Policy: policy}
}
func DefaultRuntime() (*Runtime, error) {
p, err := LoadPolicy()
if err != nil {
return nil, err
}
return NewRuntime(p), nil
}
func (r *Runtime) audit(e AuditEntry) {
if e.Mode == "" {
e.Mode = r.Policy.Mode
if e.Mode == "" {
e.Mode = ModeReadOnly
}
}
if r.Audit != nil {
r.Audit(e)
return
}
AppendAudit(e)
}
func (r *Runtime) loadConns() ([]SavedConnection, error) {
if r.LoadConns != nil {
return r.LoadConns()
}
return LoadConnections()
}
func (r *Runtime) findConn(id string) (*SavedConnection, error) {
if r.FindConn != nil {
return r.FindConn(id)
}
return FindConnection(id)
}
func (r *Runtime) resolveAllowed(connectionID string) (*SavedConnection, error) {
if err := r.Policy.AllowConnection(connectionID); err != nil {
return nil, err
}
c, err := r.findConn(connectionID)
if err != nil {
return nil, err
}
return c, nil
}
func (r *Runtime) execSQL(ctx context.Context, c db.ConnReq) db.ExecResult {
if r.ExecSQL != nil {
return r.ExecSQL(ctx, c)
}
return db.ExecSQL(ctx, c)
}
func (r *Runtime) listDB(ctx context.Context, c db.ConnReq) ([]string, error) {
if r.ListDB != nil {
return r.ListDB(ctx, c)
}
return db.ListDatabases(ctx, c)
}
func (r *Runtime) listTbl(ctx context.Context, c db.ConnReq) ([]string, error) {
if r.ListTbl != nil {
return r.ListTbl(ctx, c)
}
return db.ListTables(ctx, c)
}
func (r *Runtime) listCol(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, error) {
if r.ListCol != nil {
return r.ListCol(ctx, c)
}
return db.ListColumns(ctx, c)
}
// Call 统一入口:MCP 与 Agent 都走这里。args 为 JSON object。
func (r *Runtime) Call(ctx context.Context, tool string, args map[string]any) (any, error) {
switch tool {
case ToolListConnections:
return r.listConnections(ctx)
case ToolListDatabases:
return r.listDatabases(ctx, args)
case ToolListTables:
return r.listTables(ctx, args)
case ToolDescribeTable:
return r.describeTable(ctx, args)
case ToolGetSchemaContext:
return r.getSchemaContext(ctx, args)
case ToolExecuteQuery:
return r.executeQuery(ctx, args)
case ToolExecuteRedisCommand:
return r.executeRedis(ctx, args)
default:
return nil, fmt.Errorf("unknown tool: %s", tool)
}
}
func (r *Runtime) listConnections(ctx context.Context) (any, error) {
_ = ctx
list, err := r.loadConns()
if err != nil {
r.audit(AuditEntry{Tool: ToolListConnections, OK: false, Error: err.Error()})
return nil, err
}
out := make([]ConnectionSummary, 0, len(list))
for _, c := range list {
if err := r.Policy.AllowConnection(c.ID); err != nil {
continue
}
out = append(out, c.Summary())
}
r.audit(AuditEntry{Tool: ToolListConnections, OK: true, Summary: fmt.Sprintf("%d connections", len(out))})
return map[string]any{"connections": out}, nil
}
func argString(args map[string]any, key string) string {
if args == nil {
return ""
}
v, ok := args[key]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}
func argInt(args map[string]any, key string, def int) int {
if args == nil {
return def
}
v, ok := args[key]
if !ok || v == nil {
return def
}
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
var n int
fmt.Sscanf(t, "%d", &n)
if n == 0 {
return def
}
return n
default:
return def
}
}
func (r *Runtime) listDatabases(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
dbs, err := r.listDB(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d databases", len(dbs))})
return map[string]any{"databases": dbs}, nil
}
func (r *Runtime) listTables(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
database := argString(args, "database")
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
tables, err := r.listTbl(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d tables", len(tables))})
return map[string]any{"tables": tables, "database": req.Database}, nil
}
func (r *Runtime) describeTable(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
database := argString(args, "database")
table := argString(args, "table")
if table == "" {
err := fmt.Errorf("table required")
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
req.Table = table
cols, err := r.listCol(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: true, Summary: table + fmt.Sprintf(" (%d cols)", len(cols))})
return map[string]any{"table": table, "database": req.Database, "columns": cols}, nil
}
func (r *Runtime) getSchemaContext(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
database := argString(args, "database")
maxTables := argInt(args, "max_tables", 40)
if maxTables <= 0 {
maxTables = 40
}
if maxTables > 200 {
maxTables = 200
}
c, err := r.resolveAllowed(id)
if err != nil {
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
tables, err := r.listTbl(ctx, req)
if err != nil {
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: false, Error: err.Error()})
return nil, err
}
var b strings.Builder
n := 0
for _, t := range tables {
if n >= maxTables {
break
}
colReq := req
colReq.Table = t
cols, err := r.listCol(ctx, colReq)
if err != nil {
continue
}
parts := make([]string, 0, len(cols))
for _, col := range cols {
parts = append(parts, strings.TrimSpace(col.Name+" "+col.Type))
}
dbName := req.Database
if dbName == "" {
dbName = "default"
}
line := fmt.Sprintf("%s.%s(%s)\n", dbName, t, strings.Join(parts, ", "))
if b.Len()+len(line) > schemaContextCap {
break
}
b.WriteString(line)
n++
}
text := b.String()
if len(text) > schemaContextCap {
text = text[:schemaContextCap]
}
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d tables, %d chars", n, len(text))})
return map[string]any{"schema": text, "truncated": len(tables) > n || len(text) >= schemaContextCap}, nil
}
func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
sqlText := argString(args, "sql")
database := argString(args, "database")
limit := r.Policy.ClampLimit(argInt(args, "limit", 0))
fail := func(msg string) (any, error) {
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: msg, Summary: truncate(sqlText, 80)})
return nil, fmt.Errorf("%s", msg)
}
if sqlText == "" {
return fail("sql required")
}
// P0 纯只读:写/DDL 一律拒绝;不做 write mode / confirm token。
if ok, reason := IsReadOnlySQL(sqlText); !ok {
return fail(reason)
}
c, err := r.resolveAllowed(id)
if err != nil {
return fail(err.Error())
}
if strings.EqualFold(c.Driver, "redis") {
return fail("redis connections do not accept SQL; use opsxdb_execute_redis_command")
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
req.SQL = sqlText
req.Limit = limit
req.ConfirmDanger = false // 永不带确认放行
res := r.execSQL(ctx, req)
if res.NeedConfirm {
// 只读层已拦;若底层仍标 needConfirm,视为拒绝
return fail("dangerous sql blocked (needConfirm): " + res.Error)
}
if res.Error != "" {
return fail(res.Error)
}
r.audit(AuditEntry{
Tool: ToolExecuteQuery, ConnectionID: id, OK: true,
Summary: fmt.Sprintf("rows=%d truncated=%v sql=%s", res.RowCount, res.Truncated, truncate(sqlText, 60)),
})
return map[string]any{
"columns": res.Columns,
"rows": res.Rows,
"rowCount": res.RowCount,
"timeMs": res.TimeMs,
"truncated": res.Truncated,
"limit": limit,
}, nil
}
func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, error) {
id := argString(args, "connection_id")
command := argString(args, "command")
database := argString(args, "database")
fail := func(msg string) (any, error) {
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: msg, Summary: truncate(command, 80)})
return nil, fmt.Errorf("%s", msg)
}
if command == "" {
return fail("command required")
}
cmd, err := CheckRedisReadOnly(command)
if err != nil {
return fail(err.Error())
}
_ = cmd
c, err := r.resolveAllowed(id)
if err != nil {
return fail(err.Error())
}
if !strings.EqualFold(c.Driver, "redis") {
return fail("connection is not redis")
}
req := c.ToConnReq()
if database != "" {
req.Database = database
}
req.SQL = command
req.ConfirmDanger = false
res := r.execSQL(ctx, req)
if res.NeedConfirm {
return fail("dangerous redis command blocked: " + res.Error)
}
if res.Error != "" {
return fail(res.Error)
}
r.audit(AuditEntry{
Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: true,
Summary: fmt.Sprintf("cmd=%s rows=%d", cmd, res.RowCount),
})
return map[string]any{
"columns": res.Columns,
"rows": res.Rows,
"rowCount": res.RowCount,
"timeMs": res.TimeMs,
"truncated": res.Truncated,
}, nil
}
func truncate(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// ResultJSON 将 Call 结果序列化为 MCP 文本内容。
func ResultJSON(v any) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
+229
View File
@@ -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)
}
}
+30
View File
@@ -0,0 +1,30 @@
package aitools
import (
"encoding/base64"
"net/url"
"strings"
)
const secretPrefix = "ox1."
// DecodeSecret 与前端 src/secrets.ts decodeSecret 对齐:ox1. + base64(utf8)。 // Logan.Li
func DecodeSecret(stored string) string {
if stored == "" {
return ""
}
if !strings.HasPrefix(stored, secretPrefix) {
return stored
}
raw, err := base64.StdEncoding.DecodeString(stored[len(secretPrefix):])
if err != nil {
return stored
}
// 前端 encodeURIComponent 再 btoa;解码侧 escape/atob/decodeURIComponent。
// Go 侧:先按 latin1 字节还原,再尝试 URL unescape。
s := string(raw)
if u, err := url.QueryUnescape(s); err == nil {
return u
}
return s
}