From b60782959dd963df166dc77fc46e5a24f2ec4651 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sat, 7 Mar 2026 17:59:09 +0000 Subject: [PATCH] feat: integrar RBAC y allowlist de usuarios en runtime y listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cambios en 3 archivos: - agents/runtime.go: construye ACL desde config de roles, verifica permisos antes de ejecutar comandos (command:), interacción LLM (ask) y ejecución de tools (tool:). Mensajes denegados se loguean y responden al usuario. - shell/matrix/listener.go: filtra invites y mensajes de usuarios no autorizados cuando se configura allowed_users (allowlist vacía = todos). - internal/config/schema.go: añade campos AllowedUsers y UnauthorizedResponse a FiltersCfg para soportar la allowlist en config. Esto conecta el paquete pkg/acl con el runtime para dar soporte completo a control de acceso por rol, sin romper la compatibilidad (ACL vacío permite todo como antes). Co-Authored-By: Claude Opus 4.6 --- agents/runtime.go | 41 +++++++++++++++++++++++++++++++++++++++ internal/config/schema.go | 14 +++++++------ shell/matrix/listener.go | 32 ++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/agents/runtime.go b/agents/runtime.go index be4d746..aace44b 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -15,6 +15,7 @@ import ( "maunium.net/go/mautrix/event" "github.com/enmanuel/agents/internal/config" + "github.com/enmanuel/agents/pkg/acl" "github.com/enmanuel/agents/pkg/command" "github.com/enmanuel/agents/pkg/decision" coretypes "github.com/enmanuel/agents/pkg/llm" @@ -60,6 +61,9 @@ type Agent struct { logger *slog.Logger cryptoStore io.Closer // non-nil when E2EE is enabled; closed on shutdown + // Access control + acl acl.ACL + // Commands — handlers keyed by canonical name; cmdAliases maps alias → canonical commands map[string]CommandHandler cmdAliases map[string]string // alias → canonical name @@ -209,11 +213,22 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* } } + // Build ACL from security roles config + aclRoles := make(map[string]acl.RoleDef, len(cfg.Security.Roles)) + for name, r := range cfg.Security.Roles { + aclRoles[name] = acl.RoleDef{Users: r.Users, Actions: r.Actions} + } + agentACL := acl.FromMap(aclRoles) + if !agentACL.Empty() { + logger.Info("acl enabled", "roles", len(cfg.Security.Roles)) + } + // Tool registry — register tools enabled in config toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memStore, kStore, roomCtx, logger) a := &Agent{ cfg: cfg, + acl: agentACL, rules: rules, llm: llmFunc, matrix: matrixClient, @@ -497,6 +512,13 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, } if handler, ok := a.commands[cmdName]; ok { + // RBAC check for commands + if !a.acl.CanDo(msgCtx.SenderID, "command:"+cmdName) { + a.logger.Info("command_denied", "command", cmdName, "sender", msgCtx.SenderID) + _ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID, + "No tienes permisos para ejecutar este comando.") + return + } a.logger.Info("command_executed", "command", cmdName) reply := handler(ctx, msgCtx) _ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID, reply) @@ -520,6 +542,14 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, } // ── Non-command flow ───────────────────────────────────────────── + // RBAC check for LLM access ("ask" action) + if !a.acl.CanDo(msgCtx.SenderID, "ask") { + a.logger.Info("ask_denied", "sender", msgCtx.SenderID) + _ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID, + "No tienes permisos para interactuar con este agente.") + return + } + actions := decision.Evaluate(msgCtx, a.rules) a.logger.Debug("rules evaluated", "matched_actions", len(actions)) @@ -667,6 +697,17 @@ func (a *Agent) runLLM(ctx context.Context, msgCtx decision.MessageContext) (str "call_id", tc.ID, ) + // RBAC check for tool execution + if !a.acl.CanDo(msgCtx.SenderID, "tool:"+tc.Name) { + a.logger.Info("tool_denied", "tool", tc.Name, "sender", msgCtx.SenderID) + messages = append(messages, coretypes.Message{ + Role: coretypes.RoleTool, + Content: "error: permission denied for tool " + tc.Name, + ToolCallID: tc.ID, + }) + continue + } + // Notify the room that a tool is being called toolNotice := fmt.Sprintf("🔨 %s", tc.Name) if err := a.matrix.SendMarkdown(ctx, msgCtx.RoomID, toolNotice); err != nil { diff --git a/internal/config/schema.go b/internal/config/schema.go index b6c44df..60e9544 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -209,12 +209,14 @@ type RoomsCfg struct { } type FiltersCfg struct { - CommandPrefix string `yaml:"command_prefix"` - MentionRespond bool `yaml:"mention_respond"` - DMRespond bool `yaml:"dm_respond"` - IgnoreBots bool `yaml:"ignore_bots"` - IgnoreUsers []string `yaml:"ignore_users"` - MinPowerLevel int `yaml:"min_power_level"` + CommandPrefix string `yaml:"command_prefix"` + MentionRespond bool `yaml:"mention_respond"` + DMRespond bool `yaml:"dm_respond"` + IgnoreBots bool `yaml:"ignore_bots"` + IgnoreUsers []string `yaml:"ignore_users"` + AllowedUsers []string `yaml:"allowed_users"` // allowlist (empty = allow all) + UnauthorizedResponse string `yaml:"unauthorized_response"` // silent (default) | explicit + MinPowerLevel int `yaml:"min_power_level"` } // ── Inter-agent ─────────────────────────────────────────────────────────── diff --git a/shell/matrix/listener.go b/shell/matrix/listener.go index 80eedae..9a8e666 100644 --- a/shell/matrix/listener.go +++ b/shell/matrix/listener.go @@ -84,6 +84,23 @@ func (l *Listener) Run(ctx context.Context) error { if membership != event.MembershipInvite { return } + + // Invite gating: if allowlist is configured, reject invites from unauthorized users + if len(l.cfg.Filters.AllowedUsers) > 0 { + allowed := false + for _, u := range l.cfg.Filters.AllowedUsers { + if evt.Sender.String() == u { + allowed = true + break + } + } + if !allowed { + l.logger.Info("rejecting invite from unauthorized user", + "room", evt.RoomID, "inviter", evt.Sender) + return + } + } + l.logger.Info("received room invite, joining", "room", evt.RoomID, "inviter", evt.Sender) if _, err := l.client.raw.JoinRoom(ctx, evt.RoomID.String(), "", nil); err != nil { l.logger.Error("failed to auto-join room", "room", evt.RoomID, "err", err) @@ -208,6 +225,21 @@ func (l *Listener) shouldHandle(evt *event.Event) bool { } } + // Check allowlist — if configured, only allowed users can talk to the bot + if len(f.AllowedUsers) > 0 { + allowed := false + for _, u := range f.AllowedUsers { + if evt.Sender.String() == u { + allowed = true + break + } + } + if !allowed { + l.logger.Debug("ignoring unauthorized user", "sender", evt.Sender) + return false + } + } + // Check if room is in the listen list if len(l.cfg.Rooms.Listen) > 0 { allowed := false