feat(ai): session harness contract — incomplete, run_id, confirm bind

Add docs/16 for session state machine, incomplete/resume, queue semantics,
and agent-loop/harness/MCP gap review. Wire RunOutcome on max-rounds,
run_id through audit, TrimMessages/Redact hooks, ConfirmBindHash on write
confirm path; session file skeleton without HTTP yet. No push.
This commit is contained in:
logan committed 2026-10-15 13:58:10 +08:00
1 parent 0aeee2b692
commit 5416e86379
17 files changed
+1041 -61

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ type AuditEntry struct {
ConnectionID string `json:"connection_id,omitempty"`
Mode string `json:"mode"`
Source string `json:"source,omitempty"` // mcp | agent
RunID string `json:"run_id,omitempty"`
OK bool `json:"ok"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
+29
View File
@@ -0,0 +1,29 @@
package aitools
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// ConfirmBindHash 绑定 connection + 规范化语句;改语句则哈希变,须重确认。
func ConfirmBindHash(connectionID, statement string) string {
connectionID = strings.TrimSpace(connectionID)
statement = strings.Join(strings.Fields(strings.TrimSpace(statement)), " ")
sum := sha256.Sum256([]byte(connectionID + "\n" + statement))
return hex.EncodeToString(sum[:])
}
// VerifyConfirmBind 校验客户端带回的 bind;空 bind 视为不合法(桌面路径必须带)。
func VerifyConfirmBind(connectionID, statement, bind string) error {
bind = strings.TrimSpace(bind)
if bind == "" {
return fmt.Errorf("confirmBind required")
}
want := ConfirmBindHash(connectionID, statement)
if !strings.EqualFold(bind, want) {
return fmt.Errorf("confirmBind mismatch: statement or connection changed; re-confirm required")
}
return nil
}
@@ -0,0 +1,57 @@
package aitools
import "testing"
func TestConfirmBindHashStableAndSensitive(t *testing.T) {
a := ConfirmBindHash("c1", "INSERT INTO t VALUES (1)")
b := ConfirmBindHash("c1", " INSERT INTO t VALUES (1) ")
if a != b {
t.Fatalf("normalize mismatch %s vs %s", a, b)
}
c := ConfirmBindHash("c1", "INSERT INTO t VALUES (2)")
if a == c {
t.Fatal("different sql should differ")
}
if err := VerifyConfirmBind("c1", "INSERT INTO t VALUES (1)", a); err != nil {
t.Fatal(err)
}
if err := VerifyConfirmBind("c1", "INSERT INTO t VALUES (2)", a); err == nil {
t.Fatal("want mismatch")
}
if err := VerifyConfirmBind("c1", "INSERT INTO t VALUES (1)", ""); err == nil {
t.Fatal("want empty bind error")
}
}
func TestNeedConfirmIncludesBind(t *testing.T) {
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: "127.0.0.1"}, nil
}
rt.Audit = func(AuditEntry) {}
out, err := rt.Call(nil, ToolExecuteQuery, map[string]any{
"connection_id": "c1",
"sql": "DELETE FROM users WHERE id=1",
})
if err != nil {
t.Fatal(err)
}
m, ok := out.(map[string]any)
if !ok || m["needConfirm"] != true {
t.Fatalf("%v", out)
}
bind, _ := m["confirmBind"].(string)
if bind == "" {
t.Fatal("missing confirmBind")
}
want := ConfirmBindHash("c1", "DELETE FROM users WHERE id=1")
if bind != want {
t.Fatalf("bind %s want %s", bind, want)
}
}
+17 -8
View File
@@ -53,6 +53,8 @@ type Runtime struct {
AllowConfirmedWrite bool
// Source 写入审计:mcp | agent。
Source string
// RunID 写入审计,串联一次 Agent run。
RunID string
}
func NewRuntime(policy Policy) *Runtime {
@@ -77,6 +79,9 @@ func (r *Runtime) audit(e AuditEntry) {
if e.Source == "" {
e.Source = r.Source
}
if e.RunID == "" && r.RunID != "" {
e.RunID = r.RunID
}
if r.Audit != nil {
r.Audit(e)
return
@@ -399,11 +404,13 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
// P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。
if !confirmed {
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
bind := ConfirmBindHash(id, sqlText)
return map[string]any{
"needConfirm": true,
"sql": sqlText,
"message": "write requires explicit user confirmation",
"reason": readReason,
"needConfirm": true,
"sql": sqlText,
"confirmBind": bind,
"message": "write requires explicit user confirmation",
"reason": readReason,
}, nil
}
}
@@ -471,11 +478,13 @@ func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, e
}
if !confirmed {
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(command, 80)})
bind := ConfirmBindHash(id, command)
return map[string]any{
"needConfirm": true,
"command": command,
"message": "redis write requires explicit user confirmation",
"reason": rerr.Error(),
"needConfirm": true,
"command": command,
"confirmBind": bind,
"message": "redis write requires explicit user confirmation",
"reason": rerr.Error(),
}, nil
}
}