feat: conectar sistema centralizado de seguridad al launcher y runtime

- Migrar admin a security/user-groups.yaml (admins group)
- agents.New() ahora acepta acl.ACL pre-resuelta como parámetro;
  elimina construcción interna desde cfg.Security.Roles
- cmd/launcher: carga shellsecurity.Load("security/") al arranque;
  si falla, WARN + política vacía (open access). Para cada agente
  llama pksecurity.ResolveACL y pasa la ACL a agents.New()
- cmd/launcher/registry.go: stores secPolicy en launchDeps para
  que reload() también resuelva ACL centralmente
- shell/matrix/listener.go: elimina invite gating y allowlist check
  basados en AllowedUsers; el control de acceso lo hace el runtime
- internal/config/schema.go: depreca campos Roles y AllowedUsers
  (backward compat, no eliminados)
- agents/*/config.yaml: elimina bloques security.roles y allowed_users
- dev/feature_flags.json: activa centralized-security-groups (enabled: true)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:56:21 +00:00
parent ebd8ea3789
commit 8811d45fd1
9 changed files with 50 additions and 80 deletions
-9
View File
@@ -162,7 +162,6 @@ matrix:
dm_respond: true
ignore_bots: true
ignore_users: []
allowed_users: [] # vacío = sin restricción (todos pueden hablar)
unauthorized_response: silent # silent | explicit
min_power_level: 0
@@ -208,14 +207,6 @@ ssh:
# PERMISOS Y SEGURIDAD
# ============================================
security:
roles:
admin:
users: ["@admin:matrix-af2f3d.organic-machine.com"]
actions: ["*"]
user:
users: ["*"]
actions: ["*"]
audit:
enabled: false
log_file: "./agents/asistente-2/data/audit.log"
-12
View File
@@ -162,10 +162,6 @@ matrix:
dm_respond: true # responde en DMs (modo principal por ahora)
ignore_bots: true
ignore_users: []
allowed_users: [] # vacío = sin restricción (todos pueden hablar)
# allowed_users: # ejemplo con restricción:
# - "@admin:matrix-af2f3d.organic-machine.com"
# - "@enmanuel:matrix-af2f3d.organic-machine.com"
unauthorized_response: silent # silent | explicit
min_power_level: 0 # cualquiera puede hablar con el assistant
@@ -208,14 +204,6 @@ ssh:
# PERMISOS Y SEGURIDAD
# ============================================
security:
roles:
admin:
users: ["@admin:matrix-af2f3d.organic-machine.com"]
actions: ["*"]
user:
users: ["*"]
actions: ["*"]
audit:
enabled: false
log_file: "./agents/assistant-bot/data/audit.log"
+5 -9
View File
@@ -115,8 +115,10 @@ func (a *Agent) ClearWindow(roomID string) {
}
}
// New assembles an Agent from its config, rules, and logger.
func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*Agent, error) {
// New assembles an Agent from its config, rules, pre-resolved ACL, and logger.
// The ACL is resolved externally (e.g. from security/ YAML files) and injected here.
// Pass acl.ACL{} (empty) for open access (no restrictions).
func New(cfg *config.AgentConfig, rules []decision.Rule, agentACL acl.ACL, logger *slog.Logger) (*Agent, error) {
// Matrix client
matrixClient, err := matrix.New(cfg.Matrix)
if err != nil {
@@ -230,14 +232,8 @@ 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))
logger.Info("acl enabled (centralized security policy)")
}
// Tool registry — register tools enabled in config
+29 -6
View File
@@ -26,9 +26,11 @@ import (
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/decision"
"github.com/enmanuel/agents/pkg/orchestration"
pksecurity "github.com/enmanuel/agents/pkg/security"
"github.com/enmanuel/agents/shell/bus"
agentlog "github.com/enmanuel/agents/shell/logger"
orchshell "github.com/enmanuel/agents/shell/orchestration"
shellsecurity "github.com/enmanuel/agents/shell/security"
)
// rulesRegistry maps agent IDs to their rule factories.
@@ -81,6 +83,19 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// ── Load centralized security policy ──
secPolicy, secErr := shellsecurity.Load("security/")
if secErr != nil {
logger.Warn("security policy load failed, using empty policy (open access)", "err", secErr)
secPolicy = pksecurity.SecurityPolicy{}
} else {
logger.Info("security policy loaded",
"user_groups", len(secPolicy.UserGroups),
"agent_groups", len(secPolicy.AgentGroups),
"policies", len(secPolicy.Policies),
)
}
// ── Shared bus for inter-agent communication ──
agentBus := bus.New(logger)
@@ -95,11 +110,12 @@ func main() {
// ── Shared dependencies for agent registry ──
deps := &launchDeps{
agentBus: agentBus,
orch: orch,
logDir: logDir,
logLevel: lvl,
parentCtx: ctx,
agentBus: agentBus,
orch: orch,
logDir: logDir,
logLevel: lvl,
parentCtx: ctx,
secPolicy: secPolicy,
}
registry := newAgentRegistry(deps)
@@ -158,7 +174,14 @@ func main() {
agentCleanup = func() {}
}
a, err := agents.New(cfg, rules, agentLogger)
// Resolve centralized ACL for this agent
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, deps.secPolicy)
agentLogger.Debug("resolved acl for agent",
"agent", cfg.Agent.ID,
"acl_empty", agentACL.Empty(),
)
a, err := agents.New(cfg, rules, agentACL, agentLogger)
if err != nil {
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", err)
agentCleanup()
+10 -6
View File
@@ -12,6 +12,7 @@ import (
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/decision"
"github.com/enmanuel/agents/pkg/orchestration"
pksecurity "github.com/enmanuel/agents/pkg/security"
"github.com/enmanuel/agents/shell/bus"
agentlog "github.com/enmanuel/agents/shell/logger"
)
@@ -27,11 +28,12 @@ type runningAgent struct {
// launchDeps holds shared resources needed to start/reload agents.
type launchDeps struct {
agentBus *bus.Bus
orch *orchHandle
logDir string
logLevel slog.Level
parentCtx context.Context
agentBus *bus.Bus
orch *orchHandle
logDir string
logLevel slog.Level
parentCtx context.Context
secPolicy pksecurity.SecurityPolicy // centralized security policy loaded from security/
}
// agentRegistry tracks all running agents by ID, enabling individual hot-reload.
@@ -133,7 +135,9 @@ func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) []
// 5. Create new agent (validates config before discarding the old one).
rules := rulesFor(cfg.Agent.ID, newLogger)
newAgent, err := agents.New(cfg, rules, newLogger)
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, r.deps.secPolicy)
newLogger.Debug("resolved acl for agent (reload)", "agent", cfg.Agent.ID, "acl_empty", agentACL.Empty())
newAgent, err := agents.New(cfg, rules, agentACL, newLogger)
if err != nil {
newLogger.Error("reload: failed to create agent", "id", id, "err", err)
newCleanup()
+1 -1
View File
@@ -7,7 +7,7 @@
"added": "2026-03-07"
},
"centralized-security-groups": {
"enabled": false,
"enabled": true,
"issue": "0024",
"description": "Sistema centralizado de grupos de usuarios y agentes para control de acceso; elimina security.roles y allowed_users por agente",
"added": "2026-03-08"
+4 -1
View File
@@ -227,7 +227,8 @@ type FiltersCfg struct {
DMRespond bool `yaml:"dm_respond"`
IgnoreBots bool `yaml:"ignore_bots"`
IgnoreUsers []string `yaml:"ignore_users"`
AllowedUsers []string `yaml:"allowed_users"` // allowlist (empty = allow all)
// Deprecated: use security/ centralized groups instead. Kept for backward compatibility.
AllowedUsers []string `yaml:"allowed_users"`
UnauthorizedResponse string `yaml:"unauthorized_response"` // silent (default) | explicit
MinPowerLevel int `yaml:"min_power_level"`
}
@@ -287,6 +288,8 @@ type SSHTargetCfg struct {
// ── Security ──────────────────────────────────────────────────────────────
type SecurityCfg struct {
// Deprecated: use security/ centralized groups instead (see security/user-groups.yaml, permissions.yaml).
// Kept for backward compatibility; will be removed in a future issue.
Roles map[string]RoleCfg `yaml:"roles"`
Audit AuditCfg `yaml:"audit"`
Secrets SecretsCfg `yaml:"secrets"`
+1 -1
View File
@@ -2,6 +2,6 @@
# Members: lista de Matrix user IDs, o "*" para todos los usuarios
groups:
admins:
members: [] # rellenar con los administradores reales
members: ["@admin:matrix-af2f3d.organic-machine.com"]
everyone:
members: ["*"]
-35
View File
@@ -102,22 +102,6 @@ func (l *Listener) Run(ctx context.Context) error {
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)
@@ -281,25 +265,6 @@ 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)
if f.UnauthorizedResponse == "explicit" {
ctx := context.Background()
_ = l.client.SendText(ctx, evt.RoomID.String(), "No tienes permisos para interactuar con este agente.")
}
return false
}
}
// Check if room is in the listen list
if len(l.cfg.Rooms.Listen) > 0 {
allowed := false