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

+1
View File
@@ -14,6 +14,7 @@ type AuditEntry struct {
Tool string `json:"tool"`
ConnectionID string `json:"connection_id,omitempty"`
Mode string `json:"mode"`
Source string `json:"source,omitempty"` // mcp | agent
OK bool `json:"ok"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
+5 -1
View File
@@ -81,10 +81,14 @@ func LoadPolicy() (Policy, error) {
}
func (p Policy) IsReadOnly() bool {
// P0:即使配置写了 read_write,执行层仍按只读拦截;此 helper 表示「策略意图」。
return p.Mode != ModeReadWrite
}
// WritesEnabled P1:仅当 mcp.json mode=read_write 时允许写路径(仍须确认令牌)。
func (p Policy) WritesEnabled() bool {
return strings.EqualFold(strings.TrimSpace(p.Mode), ModeReadWrite)
}
func (p Policy) AllowConnection(id string) error {
id = strings.TrimSpace(id)
if id == "" {
+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 {
+72
View File
@@ -227,3 +227,75 @@ func TestToolNamesStable(t *testing.T) {
t.Fatalf("%v", names)
}
}
func TestExecuteQueryNeedConfirmInReadWrite(t *testing.T) {
called := false
rt := NewRuntime(Policy{
Mode: ModeReadWrite,
AllowedConnectionIDs: []string{"c1"},
DefaultLimit: 100,
MaxLimit: 500,
})
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Name: "d", Driver: "mysql", Host: "127.0.0.1", Port: 3306}, nil
}
rt.ExecSQL = func(_ context.Context, c db.ConnReq) db.ExecResult {
called = true
return db.ExecResult{RowCount: 1}
}
rt.Audit = func(AuditEntry) {}
out, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1",
"sql": "DELETE FROM users WHERE id=1",
})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if m["needConfirm"] != true {
t.Fatalf("want needConfirm, got %#v", m)
}
if called {
t.Fatal("must not exec without confirm")
}
out2, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1",
"sql": "DELETE FROM users WHERE id=1",
"confirm_danger": true,
})
if err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("expected exec after confirm")
}
_ = out2
}
func TestExecuteQueryRejectConfirmWhenAgentFlag(t *testing.T) {
called := false
rt := NewRuntime(Policy{Mode: ModeReadWrite, AllowedConnectionIDs: []string{"c1"}, DefaultLimit: 100, MaxLimit: 500})
rt.RejectCallerConfirm = true
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Driver: "mysql", Host: "h", Port: 1}, nil
}
rt.ExecSQL = func(_ context.Context, c db.ConnReq) db.ExecResult {
called = true
return db.ExecResult{}
}
rt.Audit = func(AuditEntry) {}
out, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1", "sql": "UPDATE t SET a=1", "confirm_danger": true,
})
if err != nil {
t.Fatal(err)
}
if out.(map[string]any)["needConfirm"] != true {
t.Fatalf("%#v", out)
}
if called {
t.Fatal("agent must not auto-confirm")
}
}
+11 -9
View File
@@ -52,22 +52,24 @@ func ToolSpecs() []ToolSpec {
},
{
Name: ToolExecuteQuery,
Description: "Execute a READ-ONLY SQL query (SELECT/SHOW/EXPLAIN/WITH…SELECT). Writes and DDL are rejected in P0. Default limit 100, max 500.",
Description: "Execute SQL. Default read-only (SELECT/SHOW/EXPLAIN/WITH…SELECT). Writes require mcp.json mode=read_write AND confirm_danger=true (never auto). Default limit 100, max 500.",
Properties: map[string]any{
"connection_id": connID,
"sql": map[string]any{"type": "string", "description": "Read-only SQL"},
"database": database,
"limit": map[string]any{"type": "integer", "description": "Row limit (default 100, max 500)"},
"connection_id": connID,
"sql": map[string]any{"type": "string", "description": "SQL statement"},
"database": database,
"limit": map[string]any{"type": "integer", "description": "Row limit (default 100, max 500)"},
"confirm_danger": map[string]any{"type": "boolean", "description": "Required for writes when mode=read_write; Agent UI sets this only after user confirms"},
},
Required: []string{"connection_id", "sql"},
},
{
Name: ToolExecuteRedisCommand,
Description: "Execute a READ-ONLY Redis command (GET/SCAN/TYPE/TTL/…). FLUSH*/KEYS and writes are rejected in P0.",
Description: "Execute Redis command. Default read-only whitelist. Writes require mode=read_write AND confirm_danger=true. FLUSH*/KEYS/CONFIG always denied.",
Properties: map[string]any{
"connection_id": connID,
"command": map[string]any{"type": "string", "description": "Single Redis command line"},
"database": database,
"connection_id": connID,
"command": map[string]any{"type": "string", "description": "Single Redis command line"},
"database": database,
"confirm_danger": map[string]any{"type": "boolean", "description": "Required for writes when mode=read_write"},
},
Required: []string{"connection_id", "command"},
},
+98
View File
@@ -0,0 +1,98 @@
package aitools
import (
"bufio"
"encoding/json"
"os"
"time"
)
const mcpActiveWindow = 5 * time.Minute
// MCPStatus 供侧栏 MCP 状态点 / 设置页轻量指示。
type MCPStatus struct {
Configured bool `json:"configured"`
Mode string `json:"mode"`
WhitelistCount int `json:"whitelistCount"`
WritesEnabled bool `json:"writesEnabled"`
Active bool `json:"active"` // 近 5 分钟有 source=mcp 审计
Recent []AuditEntry `json:"recent,omitempty"`
PolicyPath string `json:"policyPath,omitempty"`
AuditPath string `json:"auditPath,omitempty"`
}
// GetMCPStatus 读 mcp.json + 尾部审计;不探测外部 Cursor 进程。
func GetMCPStatus() MCPStatus {
st := MCPStatus{Mode: ModeReadOnly}
if path, err := PolicyPath(); err == nil {
st.PolicyPath = path
if _, err := os.Stat(path); err == nil {
st.Configured = true
}
}
if p, err := LoadPolicy(); err == nil {
st.Mode = p.Mode
if st.Mode == "" {
st.Mode = ModeReadOnly
}
st.WhitelistCount = len(p.AllowedConnectionIDs)
st.WritesEnabled = p.WritesEnabled()
st.Configured = st.Configured || st.WhitelistCount > 0
}
if ap, err := AuditPath(); err == nil {
st.AuditPath = ap
recent := tailAudit(ap, 20)
st.Recent = filterSource(recent, "mcp")
if len(st.Recent) == 0 {
// 兼容旧审计无 source:仍展示最近条目,但不标 active
st.Recent = recent
} else {
cutoff := time.Now().Add(-mcpActiveWindow)
for _, e := range st.Recent {
if t, err := time.Parse(time.RFC3339, e.Time); err == nil && t.After(cutoff) {
st.Active = true
break
}
}
}
}
return st
}
func filterSource(in []AuditEntry, source string) []AuditEntry {
var out []AuditEntry
for _, e := range in {
if e.Source == source {
out = append(out, e)
}
}
return out
}
func tailAudit(path string, n int) []AuditEntry {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var lines []string
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
lines = append(lines, sc.Text())
if len(lines) > n*4 { // 粗裁
lines = lines[len(lines)-n*2:]
}
}
if len(lines) > n {
lines = lines[len(lines)-n:]
}
out := make([]AuditEntry, 0, len(lines))
for _, line := range lines {
var e AuditEntry
if json.Unmarshal([]byte(line), &e) == nil {
out = append(out, e)
}
}
return out
}