ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
375 lines
13 KiB
Go
375 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// InterceptRule is one row of intercept_rules.
|
|
type InterceptRule struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Priority int `json:"priority"`
|
|
MatchTarget string `json:"match_target"`
|
|
MatchType string `json:"match_type"`
|
|
Pattern string `json:"pattern"`
|
|
Action string `json:"action"`
|
|
Message string `json:"message"`
|
|
TimeoutEnabled bool `json:"timeout_enabled"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
TimeoutAction string `json:"timeout_action"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// InterceptPending is one row of intercept_pending.
|
|
type InterceptPending struct {
|
|
ID int64 `json:"id"`
|
|
RuleID *int64 `json:"rule_id"`
|
|
ConversationID *int64 `json:"conversation_id"`
|
|
TaskID *string `json:"task_id"`
|
|
AgentName string `json:"agent_name"`
|
|
ToolName string `json:"tool_name"`
|
|
ToolInput json.RawMessage `json:"tool_input"`
|
|
Status string `json:"status"`
|
|
DecisionSource string `json:"decision_source"`
|
|
Reason string `json:"reason"` // 规则 message 或模型判定理由(前缀 [模型])
|
|
DecidedAt *time.Time `json:"decided_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
const interceptRuleCols = `id, name, enabled, priority, match_target, match_type, pattern, action, message, timeout_enabled, timeout_seconds, timeout_action, created_at, updated_at`
|
|
|
|
func scanInterceptRule(row interface{ Scan(...any) error }) (InterceptRule, error) {
|
|
var r InterceptRule
|
|
err := row.Scan(&r.ID, &r.Name, &r.Enabled, &r.Priority,
|
|
&r.MatchTarget, &r.MatchType, &r.Pattern, &r.Action, &r.Message,
|
|
&r.TimeoutEnabled, &r.TimeoutSeconds, &r.TimeoutAction,
|
|
&r.CreatedAt, &r.UpdatedAt)
|
|
return r, err
|
|
}
|
|
|
|
// ListInterceptRules returns all rules ordered by priority DESC then id.
|
|
func (d *DB) ListInterceptRules() ([]InterceptRule, error) {
|
|
rows, err := d.Query(`SELECT ` + interceptRuleCols + ` FROM intercept_rules ORDER BY priority DESC, id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []InterceptRule
|
|
for rows.Next() {
|
|
r, err := scanInterceptRule(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CreateInterceptRule inserts a new rule.
|
|
func (d *DB) CreateInterceptRule(name, matchTarget, matchType, pattern, action, message string, priority int, enabled bool, timeoutEnabled bool, timeoutSeconds int, timeoutAction string) (InterceptRule, error) {
|
|
row := d.QueryRow(`
|
|
INSERT INTO intercept_rules(name, enabled, priority, match_target, match_type, pattern, action, message, timeout_enabled, timeout_seconds, timeout_action)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
RETURNING `+interceptRuleCols,
|
|
name, enabled, priority, matchTarget, matchType, pattern, action, message, timeoutEnabled, timeoutSeconds, timeoutAction)
|
|
return scanInterceptRule(row)
|
|
}
|
|
|
|
// UpdateInterceptRule replaces all editable fields of an existing rule.
|
|
func (d *DB) UpdateInterceptRule(id int64, name, matchTarget, matchType, pattern, action, message string, priority int, enabled bool, timeoutEnabled bool, timeoutSeconds int, timeoutAction string) (InterceptRule, error) {
|
|
row := d.QueryRow(`
|
|
UPDATE intercept_rules
|
|
SET name=$2, enabled=$3, priority=$4, match_target=$5,
|
|
match_type=$6, pattern=$7, action=$8, message=$9,
|
|
timeout_enabled=$10, timeout_seconds=$11, timeout_action=$12
|
|
WHERE id=$1
|
|
RETURNING `+interceptRuleCols,
|
|
id, name, enabled, priority, matchTarget, matchType, pattern, action, message, timeoutEnabled, timeoutSeconds, timeoutAction)
|
|
return scanInterceptRule(row)
|
|
}
|
|
|
|
// DeleteInterceptRule removes a rule.
|
|
func (d *DB) DeleteInterceptRule(id int64) error {
|
|
_, err := d.Exec(`DELETE FROM intercept_rules WHERE id=$1`, id)
|
|
return err
|
|
}
|
|
|
|
// ToggleInterceptRule flips the enabled state of a rule.
|
|
func (d *DB) ToggleInterceptRule(id int64, enabled bool) error {
|
|
_, err := d.Exec(`UPDATE intercept_rules SET enabled=$2 WHERE id=$1`, id, enabled)
|
|
return err
|
|
}
|
|
|
|
// CreateInterceptPending inserts a pending approval record and returns its ID.
|
|
// convID == 0 → conversation_id stored as NULL (background task).
|
|
// taskID == "" → task_id stored as NULL.
|
|
func (d *DB) CreateInterceptPending(ruleID, convID int64, taskID, agentName, toolName string, input []byte, reason string, audits ...*InterceptAudit) (int64, error) {
|
|
raw := json.RawMessage(input)
|
|
if len(raw) == 0 {
|
|
raw = json.RawMessage("{}")
|
|
}
|
|
var convIDPtr *int64
|
|
if convID != 0 {
|
|
convIDPtr = &convID
|
|
}
|
|
var taskIDPtr *string
|
|
if taskID != "" {
|
|
taskIDPtr = &taskID
|
|
}
|
|
// ruleID == 0 → NULL: the LLM fallback judge has no owning rule.
|
|
var ruleIDPtr *int64
|
|
if ruleID != 0 {
|
|
ruleIDPtr = &ruleID
|
|
}
|
|
var id int64
|
|
err := d.QueryRow(`
|
|
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, reason, decision_source, audit)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
|
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// DecideInterceptPending updates a pending record's status (allowed/denied/timeout).
|
|
func (d *DB) DecideInterceptPending(id int64, status string) error {
|
|
_, err := d.Exec(`UPDATE intercept_pending SET status=$2, decided_at=NOW() WHERE id=$1`, id, status)
|
|
return err
|
|
}
|
|
|
|
// CreateDecidedIntercept inserts an intercept_pending row ALREADY in a final state
|
|
// (status = 'allowed' | 'denied'), decided_at stamped now. Used to log allow/deny
|
|
// rule matches for observability — they don't block and need no user action, so unlike
|
|
// CreateInterceptPending (which starts 'pending') this records the outcome directly.
|
|
func (d *DB) CreateDecidedIntercept(ruleID, convID int64, taskID, agentName, toolName string, input []byte, status, reason string, audits ...*InterceptAudit) (int64, error) {
|
|
raw := json.RawMessage(input)
|
|
if len(raw) == 0 {
|
|
raw = json.RawMessage("{}")
|
|
}
|
|
var convIDPtr *int64
|
|
if convID != 0 {
|
|
convIDPtr = &convID
|
|
}
|
|
var taskIDPtr *string
|
|
if taskID != "" {
|
|
taskIDPtr = &taskID
|
|
}
|
|
// ruleID == 0 → NULL: the LLM fallback judge has no owning rule.
|
|
var ruleIDPtr *int64
|
|
if ruleID != 0 {
|
|
ruleIDPtr = &ruleID
|
|
}
|
|
var id int64
|
|
err := d.QueryRow(`
|
|
INSERT INTO intercept_pending(rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, decision_source, audit)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10) RETURNING id`,
|
|
ruleIDPtr, convIDPtr, taskIDPtr, agentName, toolName, raw, status, reason, interceptSource(ruleID, reason), firstAudit(audits)).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
const interceptPendingCols = `id, rule_id, conversation_id, task_id, agent_name, tool_name, tool_input, status, reason, decided_at, created_at, decision_source`
|
|
|
|
func scanInterceptPending(s interface{ Scan(...any) error }, p *InterceptPending) error {
|
|
return s.Scan(&p.ID, &p.RuleID, &p.ConversationID, &p.TaskID, &p.AgentName,
|
|
&p.ToolName, &p.ToolInput, &p.Status, &p.Reason, &p.DecidedAt, &p.CreatedAt, &p.DecisionSource)
|
|
}
|
|
|
|
// ListPendingIntercepts returns all unresolved approval requests, newest first.
|
|
func (d *DB) ListPendingIntercepts() ([]InterceptPending, error) {
|
|
rows, err := d.Query(`SELECT ` + interceptPendingCols + ` FROM intercept_pending WHERE status='pending' ORDER BY created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []InterceptPending
|
|
for rows.Next() {
|
|
var p InterceptPending
|
|
if err := scanInterceptPending(rows, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetInterceptPending returns one pending record (nil if absent).
|
|
func (d *DB) GetInterceptPending(id int64) (*InterceptPending, error) {
|
|
var p InterceptPending
|
|
err := scanInterceptPending(
|
|
d.QueryRow(`SELECT `+interceptPendingCols+` FROM intercept_pending WHERE id=$1`, id),
|
|
&p,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return &p, err
|
|
}
|
|
|
|
// InterceptApprovalRow is intercept_pending enriched with conversation and rule info.
|
|
type InterceptApprovalRow struct {
|
|
InterceptPending
|
|
ConvTitle string `json:"conv_title"`
|
|
ConvAgentKey string `json:"conv_agent_key"`
|
|
RuleName string `json:"rule_name"`
|
|
}
|
|
|
|
func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *InterceptApprovalRow) error {
|
|
return rows.Scan(
|
|
&r.ID, &r.RuleID, &r.ConversationID, &r.TaskID, &r.AgentName,
|
|
&r.ToolName, &r.ToolInput, &r.Status, &r.Reason, &r.DecidedAt, &r.CreatedAt,
|
|
&r.DecisionSource, &r.ConvTitle, &r.ConvAgentKey, &r.RuleName,
|
|
)
|
|
}
|
|
|
|
// Keep legacy rows without decision_source consistent with their displayed source.
|
|
const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
|
|
WHEN ip.rule_id IS NOT NULL THEN 'rule'
|
|
WHEN ip.reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END)`
|
|
|
|
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
|
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
|
|
COALESCE(c.title,'') AS conv_title,
|
|
COALESCE(c.agent_key,'') AS conv_agent_key,
|
|
COALESCE(ir.name,'') AS rule_name`
|
|
|
|
const approvalRowJoins = `
|
|
FROM intercept_pending ip
|
|
LEFT JOIN conversations c ON c.id = ip.conversation_id
|
|
LEFT JOIN intercept_rules ir ON ir.id = ip.rule_id`
|
|
|
|
const approvalRowSelect = `SELECT ` + approvalRowColumns + approvalRowJoins
|
|
const approvalRowSelectWithAudit = `SELECT ` + approvalRowColumns + `, ip.audit` + approvalRowJoins
|
|
|
|
func interceptSource(ruleID int64, reason string) string {
|
|
if ruleID != 0 {
|
|
return "rule"
|
|
}
|
|
if strings.HasPrefix(reason, "[模型]") {
|
|
return "model"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func firstAudit(audits []*InterceptAudit) any {
|
|
if len(audits) == 0 || audits[0] == nil {
|
|
return nil
|
|
}
|
|
raw, err := json.Marshal(audits[0])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// ListAllIntercepts returns up to limit intercept_pending rows (newest first)
|
|
// joined with conversation and rule info.
|
|
func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) {
|
|
rows, err := d.Query(approvalRowSelect+` ORDER BY ip.created_at DESC LIMIT $1`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []InterceptApprovalRow
|
|
for rows.Next() {
|
|
var r InterceptApprovalRow
|
|
if err := scanInterceptApprovalRow(rows, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// InterceptApprovalFilter combines exact status and decision-source filters.
|
|
// Empty fields include all values.
|
|
type InterceptApprovalFilter struct {
|
|
Status string
|
|
DecisionSource string
|
|
}
|
|
|
|
// ListAllInterceptsPage returns one 1-based page and the total matching count.
|
|
func (d *DB) ListAllInterceptsPage(page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) {
|
|
return d.listInterceptsPage("", page, size, filter)
|
|
}
|
|
|
|
// ListTaskIntercepts returns all intercept_pending rows for a specific task (newest first).
|
|
func (d *DB) ListTaskIntercepts(taskID string) ([]InterceptApprovalRow, error) {
|
|
rows, err := d.Query(approvalRowSelect+` WHERE ip.task_id=$1 ORDER BY ip.created_at DESC`, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []InterceptApprovalRow
|
|
for rows.Next() {
|
|
var r InterceptApprovalRow
|
|
if err := scanInterceptApprovalRow(rows, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListTaskInterceptsPage is the paginated variant of ListTaskIntercepts.
|
|
func (d *DB) ListTaskInterceptsPage(taskID string, page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) {
|
|
return d.listInterceptsPage(taskID, page, size, filter)
|
|
}
|
|
|
|
func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if size <= 0 {
|
|
size = 20
|
|
}
|
|
if size > 100 {
|
|
size = 100
|
|
}
|
|
offset := (page - 1) * size
|
|
|
|
conditions := []string{}
|
|
args := []any{}
|
|
add := func(column, value string) {
|
|
if value != "" {
|
|
args = append(args, value)
|
|
conditions = append(conditions, column+"=$"+fmt.Sprint(len(args)))
|
|
}
|
|
}
|
|
add("ip.task_id", taskID)
|
|
add("ip.status", filter.Status)
|
|
add(approvalDecisionSource, filter.DecisionSource)
|
|
where := ""
|
|
if len(conditions) > 0 {
|
|
where = " WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
var total int
|
|
if err := d.QueryRow("SELECT COUNT(*) FROM intercept_pending ip"+where, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
limitArg := len(args) + 1
|
|
offsetArg := limitArg + 1
|
|
dataQ := approvalRowSelect + where +
|
|
" ORDER BY ip.created_at DESC, ip.id DESC LIMIT $" + fmt.Sprint(limitArg) +
|
|
" OFFSET $" + fmt.Sprint(offsetArg)
|
|
args = append(args, size, offset)
|
|
rows, err := d.Query(dataQ, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
out := []InterceptApprovalRow{}
|
|
for rows.Next() {
|
|
var r InterceptApprovalRow
|
|
if err := scanInterceptApprovalRow(rows, &r); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|