Files
goclaw/internal/http/agents.go
T
Luan Vu a7f5acc1e3 fix(security): harden SQL injection, MCP prompt injection, sandbox fallback, and file serving (#246)
- execMapUpdate: validate column names with strict regex to prevent SQL injection
- HTTP update handlers: add field allowlists (agents, providers, custom_tools, mcp, channel_instances)
- pqStringArray: properly escape array elements to prevent PostgreSQL array literal injection
- scanStringArray: handle quoted elements in PostgreSQL array format
- MCP bridge: wrap tool results as external/untrusted content to prevent prompt injection
- File serving: block access to sensitive system directories (/etc, /proc, /sys, etc.)
- Sandbox: fail closed when Docker unavailable instead of silent fallback to host
- Shell deny: fix base64 --decode bypass, add host exec 1MB output limit
- ILIKE queries: escape % and _ wildcards in knowledge_graph, custom_tools, channel_instances

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
2026-03-18 07:42:38 +07:00

305 lines
11 KiB
Go

package http
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// AgentsHandler handles agent CRUD and sharing endpoints.
type AgentsHandler struct {
agents store.AgentStore
token string
defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace")
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
}
// NewAgentsHandler creates a handler for agent management endpoints.
// isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled).
func NewAgentsHandler(agents store.AgentStore, token, defaultWorkspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler {
return &AgentsHandler{agents: agents, token: token, defaultWorkspace: defaultWorkspace, msgBus: msgBus, summoner: summoner, isOwner: isOwner}
}
// isOwnerUser checks if the given user ID is a system owner.
func (h *AgentsHandler) isOwnerUser(userID string) bool {
return userID != "" && h.isOwner != nil && h.isOwner(userID)
}
// emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set.
func (h *AgentsHandler) emitCacheInvalidate(kind, key string) {
if h.msgBus == nil {
return
}
h.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
})
}
// RegisterRoutes registers all agent management routes on the given mux.
func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/agents", h.authMiddleware(h.handleList))
mux.HandleFunc("POST /v1/agents", h.authMiddleware(h.handleCreate))
mux.HandleFunc("GET /v1/agents/{id}", h.authMiddleware(h.handleGet))
mux.HandleFunc("PUT /v1/agents/{id}", h.authMiddleware(h.handleUpdate))
mux.HandleFunc("DELETE /v1/agents/{id}", h.authMiddleware(h.handleDelete))
mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares))
mux.HandleFunc("POST /v1/agents/{id}/shares", h.authMiddleware(h.handleShare))
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare))
mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate))
mux.HandleFunc("POST /v1/agents/{id}/resummon", h.authMiddleware(h.handleResummon))
mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances))
mux.HandleFunc("GET /v1/agents/{id}/instances/{userID}/files", h.authMiddleware(h.handleGetInstanceFiles))
mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.authMiddleware(h.handleSetInstanceFile))
mux.HandleFunc("PATCH /v1/agents/{id}/instances/{userID}/metadata", h.authMiddleware(h.handleUpdateInstanceMetadata))
}
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(h.token, "", next)
}
func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
if userID == "" {
locale := store.LocaleFromContext(r.Context())
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
return
}
var agents []store.AgentData
var err error
if h.isOwnerUser(userID) {
agents, err = h.agents.List(r.Context(), "") // owners see all agents
} else {
agents, err = h.agents.ListAccessible(r.Context(), userID)
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
}
func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
if userID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
return
}
var req store.AgentData
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
if !isValidSlug(req.AgentKey) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")})
return
}
// Check for duplicate agent_key before creating
if existing, _ := h.agents.GetByKey(r.Context(), req.AgentKey); existing != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
return
}
req.OwnerID = userID
if req.AgentType == "" {
req.AgentType = store.AgentTypeOpen
}
if req.ContextWindow <= 0 {
req.ContextWindow = 200000
}
if req.MaxToolIterations <= 0 {
req.MaxToolIterations = 20
}
if req.Workspace == "" {
req.Workspace = fmt.Sprintf("%s/%s", h.defaultWorkspace, req.AgentKey)
}
req.RestrictToWorkspace = true
// Default: enable compaction and memory for new agents
if len(req.CompactionConfig) == 0 {
req.CompactionConfig = json.RawMessage(`{}`)
}
if len(req.MemoryConfig) == 0 {
req.MemoryConfig = json.RawMessage(`{"enabled":true}`)
}
// Check if predefined agent has a description for LLM summoning
description := extractDescription(req.OtherConfig)
if req.AgentType == store.AgentTypePredefined && description != "" && h.summoner != nil {
req.Status = store.AgentStatusSummoning
} else if req.Status == "" {
req.Status = store.AgentStatusActive
}
if err := h.agents.Create(r.Context(), &req); err != nil {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
} else {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return
}
// Seed context files into agent_context_files (skipped for open agents).
// For summoning agents, templates serve as fallback if LLM fails.
if _, err := bootstrap.SeedToStore(r.Context(), h.agents, req.ID, req.AgentType); err != nil {
slog.Warn("failed to seed context files for new agent", "agent", req.AgentKey, "error", err)
}
// Start LLM summoning in background if applicable
if req.Status == store.AgentStatusSummoning {
go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
}
emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String())
writeJSON(w, http.StatusCreated, req)
}
func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
isOwner := h.isOwnerUser(userID)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
// Try by agent_key
ag, err2 := h.agents.GetByKey(r.Context(), r.PathValue("id"))
if err2 != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", r.PathValue("id"))})
return
}
if userID != "" && !isOwner {
if ok, _, _ := h.agents.CanAccess(r.Context(), ag.ID, userID); !ok {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
return
}
}
writeJSON(w, http.StatusOK, ag)
return
}
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && !isOwner {
if ok, _, _ := h.agents.CanAccess(r.Context(), id, userID); !ok {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
return
}
}
writeJSON(w, http.StatusOK, ag)
}
func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can update
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "update agent")})
return
}
var updates map[string]any
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
// Allowlist: only permit known agent columns to be updated.
// Defense-in-depth against column injection via arbitrary JSON keys.
allowed := filterAllowedKeys(updates, agentAllowedFields)
allowed["restrict_to_workspace"] = true
if err := h.agents.Update(r.Context(), id, allowed); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Invalidate caches: agent Loop + bootstrap files
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String())
// Cascade: if status changed, broadcast so channel instances and cron jobs react.
if newStatus, ok := allowed["status"].(string); ok && newStatus != ag.Status {
if h.msgBus != nil {
h.msgBus.Broadcast(bus.Event{
Name: bus.EventAgentStatusChanged,
Payload: bus.AgentStatusChangedPayload{
AgentID: id.String(),
OldStatus: ag.Status,
NewStatus: newStatus,
},
})
}
}
emitAudit(h.msgBus, r, "agent.updated", "agent", id.String())
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
userID := store.UserIDFromContext(r.Context())
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return
}
// Only owner can delete
ag, err := h.agents.GetByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
return
}
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "delete agent")})
return
}
if err := h.agents.Delete(r.Context(), id); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Invalidate caches: agent Loop + bootstrap files
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String())
emitAudit(h.msgBus, r, "agent.deleted", "agent", id.String())
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}