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:
logan committed 2026-10-15 13:00:40 +08:00
1 parent 1d6e89449a
commit 2336c9433c
17 files changed
+1324 -197

No files matched your search

+76 -9
View File
@@ -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 {