Files
opsxdb/server/internal/aitools/readonly.go
T
logan 0b68c8344d 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
2026-10-15 11:14:08 +08:00

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
}