feat: integrar RBAC y allowlist de usuarios en runtime y listener
Cambios en 3 archivos: - agents/runtime.go: construye ACL desde config de roles, verifica permisos antes de ejecutar comandos (command:<name>), interacción LLM (ask) y ejecución de tools (tool:<name>). 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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
|||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
|
|
||||||
"github.com/enmanuel/agents/internal/config"
|
"github.com/enmanuel/agents/internal/config"
|
||||||
|
"github.com/enmanuel/agents/pkg/acl"
|
||||||
"github.com/enmanuel/agents/pkg/command"
|
"github.com/enmanuel/agents/pkg/command"
|
||||||
"github.com/enmanuel/agents/pkg/decision"
|
"github.com/enmanuel/agents/pkg/decision"
|
||||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||||
@@ -60,6 +61,9 @@ type Agent struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
cryptoStore io.Closer // non-nil when E2EE is enabled; closed on shutdown
|
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 — handlers keyed by canonical name; cmdAliases maps alias → canonical
|
||||||
commands map[string]CommandHandler
|
commands map[string]CommandHandler
|
||||||
cmdAliases map[string]string // alias → canonical name
|
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
|
// Tool registry — register tools enabled in config
|
||||||
toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memStore, kStore, roomCtx, logger)
|
toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memStore, kStore, roomCtx, logger)
|
||||||
|
|
||||||
a := &Agent{
|
a := &Agent{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
acl: agentACL,
|
||||||
rules: rules,
|
rules: rules,
|
||||||
llm: llmFunc,
|
llm: llmFunc,
|
||||||
matrix: matrixClient,
|
matrix: matrixClient,
|
||||||
@@ -497,6 +512,13 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if handler, ok := a.commands[cmdName]; ok {
|
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)
|
a.logger.Info("command_executed", "command", cmdName)
|
||||||
reply := handler(ctx, msgCtx)
|
reply := handler(ctx, msgCtx)
|
||||||
_ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID, reply)
|
_ = 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 ─────────────────────────────────────────────
|
// ── 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)
|
actions := decision.Evaluate(msgCtx, a.rules)
|
||||||
a.logger.Debug("rules evaluated", "matched_actions", len(actions))
|
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,
|
"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
|
// Notify the room that a tool is being called
|
||||||
toolNotice := fmt.Sprintf("🔨 <em>%s</em>", tc.Name)
|
toolNotice := fmt.Sprintf("🔨 <em>%s</em>", tc.Name)
|
||||||
if err := a.matrix.SendMarkdown(ctx, msgCtx.RoomID, toolNotice); err != nil {
|
if err := a.matrix.SendMarkdown(ctx, msgCtx.RoomID, toolNotice); err != nil {
|
||||||
|
|||||||
@@ -209,12 +209,14 @@ type RoomsCfg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FiltersCfg struct {
|
type FiltersCfg struct {
|
||||||
CommandPrefix string `yaml:"command_prefix"`
|
CommandPrefix string `yaml:"command_prefix"`
|
||||||
MentionRespond bool `yaml:"mention_respond"`
|
MentionRespond bool `yaml:"mention_respond"`
|
||||||
DMRespond bool `yaml:"dm_respond"`
|
DMRespond bool `yaml:"dm_respond"`
|
||||||
IgnoreBots bool `yaml:"ignore_bots"`
|
IgnoreBots bool `yaml:"ignore_bots"`
|
||||||
IgnoreUsers []string `yaml:"ignore_users"`
|
IgnoreUsers []string `yaml:"ignore_users"`
|
||||||
MinPowerLevel int `yaml:"min_power_level"`
|
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 ───────────────────────────────────────────────────────────
|
// ── Inter-agent ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -84,6 +84,23 @@ func (l *Listener) Run(ctx context.Context) error {
|
|||||||
if membership != event.MembershipInvite {
|
if membership != event.MembershipInvite {
|
||||||
return
|
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)
|
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 {
|
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)
|
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
|
// Check if room is in the listen list
|
||||||
if len(l.cfg.Rooms.Listen) > 0 {
|
if len(l.cfg.Rooms.Listen) > 0 {
|
||||||
allowed := false
|
allowed := false
|
||||||
|
|||||||
Reference in New Issue
Block a user