feat(ai): Agent SSE + Anthropic/responses tools + P1 确认写
- Agent 支持 SSE(步骤边推、正文分块),JSON 非流式 fallback - tool calling 覆盖 openai-chat / anthropic / responses(单测解析回灌) - P1:mcp.json mode=read_write 才可写;需 confirm_danger;Agent 剥离模型自带令牌 - GET /api/ai/mcp-status、POST /api/ai/agent/confirm;审计 source=mcp|agent
This commit is contained in:
1 parent
1d6e89449a
commit
2336c9433c
17 files changed
+1324
-197
No files matched your search
@@ -46,6 +46,13 @@ type Runtime struct {
|
||||
ListTbl func(ctx context.Context, c db.ConnReq) ([]string, error)
|
||||
ListCol func(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, error)
|
||||
Audit func(AuditEntry)
|
||||
// RejectCallerConfirm 为 true 时(Agent 路径),忽略调用方 confirm_danger,
|
||||
// 写操作只返回 needConfirm,由用户在 UI 显式确认后再走 /api/ai/agent/confirm。
|
||||
RejectCallerConfirm bool
|
||||
// AllowConfirmedWrite 为 true 时,已校验的确认令牌可放行写(仅 confirm 端点设置)。
|
||||
AllowConfirmedWrite bool
|
||||
// Source 写入审计:mcp | agent。
|
||||
Source string
|
||||
}
|
||||
|
||||
func NewRuntime(policy Policy) *Runtime {
|
||||
@@ -67,6 +74,9 @@ func (r *Runtime) audit(e AuditEntry) {
|
||||
e.Mode = ModeReadOnly
|
||||
}
|
||||
}
|
||||
if e.Source == "" {
|
||||
e.Source = r.Source
|
||||
}
|
||||
if r.Audit != nil {
|
||||
r.Audit(e)
|
||||
return
|
||||
@@ -213,6 +223,29 @@ func argInt(args map[string]any, key string, def int) int {
|
||||
}
|
||||
}
|
||||
|
||||
func argBool(args map[string]any, key string) bool {
|
||||
if args == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(t, "true") || t == "1"
|
||||
case float64:
|
||||
return t != 0
|
||||
case int:
|
||||
return t != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (r *Runtime) listDatabases(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
c, err := r.resolveAllowed(id)
|
||||
@@ -352,9 +385,27 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
|
||||
if sqlText == "" {
|
||||
return fail("sql required")
|
||||
}
|
||||
// P0 纯只读:写/DDL 一律拒绝;不做 write mode / confirm token。
|
||||
if ok, reason := IsReadOnlySQL(sqlText); !ok {
|
||||
return fail(reason)
|
||||
callerConfirm := argBool(args, "confirm_danger") || argBool(args, "confirmDanger")
|
||||
if r.RejectCallerConfirm {
|
||||
callerConfirm = false
|
||||
}
|
||||
confirmed := r.AllowConfirmedWrite || callerConfirm
|
||||
|
||||
readOK, readReason := IsReadOnlySQL(sqlText)
|
||||
if !readOK {
|
||||
if !r.Policy.WritesEnabled() {
|
||||
return fail(readReason)
|
||||
}
|
||||
// P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。
|
||||
if !confirmed {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
|
||||
return map[string]any{
|
||||
"needConfirm": true,
|
||||
"sql": sqlText,
|
||||
"message": "write requires explicit user confirmation",
|
||||
"reason": readReason,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
c, err := r.resolveAllowed(id)
|
||||
@@ -371,7 +422,7 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
|
||||
}
|
||||
req.SQL = sqlText
|
||||
req.Limit = limit
|
||||
req.ConfirmDanger = false // 永不带确认放行
|
||||
req.ConfirmDanger = confirmed && !readOK
|
||||
|
||||
res := r.execSQL(ctx, req)
|
||||
if res.NeedConfirm {
|
||||
@@ -407,11 +458,27 @@ func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, e
|
||||
if command == "" {
|
||||
return fail("command required")
|
||||
}
|
||||
cmd, err := CheckRedisReadOnly(command)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
callerConfirm := argBool(args, "confirm_danger") || argBool(args, "confirmDanger")
|
||||
if r.RejectCallerConfirm {
|
||||
callerConfirm = false
|
||||
}
|
||||
confirmed := r.AllowConfirmedWrite || callerConfirm
|
||||
|
||||
cmd, rerr := CheckRedisReadOnly(command)
|
||||
if rerr != nil {
|
||||
if redisDeniedAlways[cmd] || !r.Policy.WritesEnabled() {
|
||||
return fail(rerr.Error())
|
||||
}
|
||||
if !confirmed {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(command, 80)})
|
||||
return map[string]any{
|
||||
"needConfirm": true,
|
||||
"command": command,
|
||||
"message": "redis write requires explicit user confirmation",
|
||||
"reason": rerr.Error(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
_ = cmd
|
||||
|
||||
c, err := r.resolveAllowed(id)
|
||||
if err != nil {
|
||||
@@ -426,7 +493,7 @@ func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, e
|
||||
req.Database = database
|
||||
}
|
||||
req.SQL = command
|
||||
req.ConfirmDanger = false
|
||||
req.ConfirmDanger = confirmed && rerr != nil
|
||||
|
||||
res := r.execSQL(ctx, req)
|
||||
if res.NeedConfirm {
|
||||
|
||||
Reference in new issue
Block a user