- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认) - 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go - 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码) - Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
120 lines
3.5 KiB
Go
120 lines
3.5 KiB
Go
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
|
|
}
|