diff --git a/dev/issues/0024-centralized-security-groups.md b/dev/issues/0024-centralized-security-groups.md new file mode 100644 index 0000000..babe79f --- /dev/null +++ b/dev/issues/0024-centralized-security-groups.md @@ -0,0 +1,199 @@ +# 0024 — Sistema centralizado de grupos y permisos + +## Objetivo + +Reemplazar los controles de acceso por agente (`security.roles`, `matrix.filters.allowed_users`) con un sistema centralizado en una carpeta `security/` donde se definen grupos de usuarios, grupos de agentes, y una política de permisos que los vincula. Esto elimina la necesidad de configurar permisos en cada agente individualmente. + +## Contexto + +- Actualmente cada agente tiene su propio bloque `security.roles` en `config.yaml` y `matrix.filters.allowed_users` en `matrix.filters`. Añadir un usuario a varios agentes requiere editar múltiples archivos. +- El módulo `pkg/acl/` existe y está completo: resuelve ACLs puras dado un mapa de roles. Lo reutilizamos como motor de evaluación. +- La nueva capa `pkg/security/` se apoya en `pkg/acl/` para producir `acl.ACL` por agente a partir de la política centralizada. +- La carpeta `security/` en la raíz del proyecto contiene los YAML de grupos y permisos. El launcher los carga una vez y distribuye la ACL resuelta a cada agente. +- Se elimina `matrix.filters.allowed_users` y `security.roles` del schema de config de agente una vez que todos los agentes usan la política centralizada. + +**Dependencias:** ninguna (issue autocontenido en 3 fases). + +## Arquitectura + +``` +pkg/security/ NEW — tipos puros + resolución ACL + groups.go NEW — UserGroup, AgentGroup + policy.go NEW — Permission, AgentPolicy, SecurityPolicy + resolver.go NEW — ResolveACL(agentID, policy) → acl.ACL + security_test.go NEW — tests de resolución + +security/ NEW — configs centralizados (raíz del proyecto) + user-groups.yaml NEW — definición de grupos de usuarios + agent-groups.yaml NEW — definición de grupos de agentes + permissions.yaml NEW — políticas: qué grupos de usuarios tienen qué permisos en qué grupos de agentes + +shell/security/ NEW — loader impuro + loader.go NEW — carga los 3 YAML y construye SecurityPolicy + loader_test.go NEW — tests con YAML de ejemplo + +cmd/launcher/main.go MODIFIED — carga security/ al inicio, pasa acl.ACL resuelta a cada Agent +agents/runtime.go MODIFIED — acepta acl.ACL pre-resuelta en lugar de RoleCfg +internal/config/schema.go MODIFIED — marcar security.roles y matrix.filters.allowed_users como deprecated +agents/assistant-bot/config.yaml MODIFIED — eliminar security.roles y allowed_users +agents/asistente-2/config.yaml MODIFIED — eliminar security.roles y allowed_users +docs/security.md MODIFIED — documentar nuevo sistema +CLAUDE.md MODIFIED — mencionar security/ en estructura +``` + +### Patron pure core / impure shell + +- `pkg/security/` — **puro**: tipos (`UserGroup`, `AgentGroup`, `SecurityPolicy`) y función `ResolveACL()`. Cero I/O. +- `shell/security/` — **impuro**: lee YAML del filesystem y construye `SecurityPolicy`. +- `cmd/launcher/` — **impuro**: llama al loader, resuelve ACL por agente, inyecta en `Agent{}`. +- `agents/runtime.go` — **composición**: recibe `acl.ACL` ya resuelta, la usa en `shouldHandle()` y en la evaluación de permisos. + +## Tareas + +### Fase 1: Pure core — pkg/security/ + +- [ ] **1.1** Crear `pkg/security/groups.go` con tipos `UserGroup{Name, Members []string}` y `AgentGroup{Name, Agents []string}` +- [ ] **1.2** Crear `pkg/security/policy.go` con tipos `Permission{UserGroup, Actions []string}`, `AgentPolicy{AgentGroup, Permissions []Permission}`, `SecurityPolicy{UserGroups, AgentGroups, Policies}` +- [ ] **1.3** Crear `pkg/security/resolver.go` con `ResolveACL(agentID string, p SecurityPolicy) acl.ACL`: expande grupos de agentes que incluyan `agentID` o `"*"`, expande grupos de usuarios a `acl.Role` list, construye `acl.ACL` vía `acl.FromRoles()` +- [ ] **1.4** Soporte de wildcard: `AgentGroup.Agents = ["*"]` aplica a todos los agentes; `UserGroup.Members = ["*"]` aplica a todos los usuarios +- [ ] **1.5** Crear `pkg/security/security_test.go` con casos: sin política (ACL vacía), agente en grupo, agente no en grupo, wildcard de agente, wildcard de usuario, múltiples políticas acumulativas + +### Fase 2: Config files + Shell loader + +- [ ] **2.1** Crear `security/user-groups.yaml` con ejemplo: grupos `admins` y `everyone` (members: `["*"]`) +- [ ] **2.2** Crear `security/agent-groups.yaml` con ejemplo: grupo `assistants` con los agentes actuales (`assistant-bot`, `asistente-2`), grupo `all` con `agents: ["*"]` +- [ ] **2.3** Crear `security/permissions.yaml` con ejemplo: grupo `all` da acción `"ask"` a `everyone`; grupo `all` da `"*"` a `admins` +- [ ] **2.4** Crear `shell/security/loader.go` con `Load(dir string) (security.SecurityPolicy, error)` que lee los 3 YAML del directorio y construye el struct. Si el directorio no existe, devuelve `SecurityPolicy{}` vacía (sin error: backward compat). +- [ ] **2.5** Crear `shell/security/loader_test.go` con tests: dir vacío → policy vacía, YAMLs válidos → policy correcta, YAML malformado → error claro + +### Fase 3: Integración en launcher y runtime + +- [ ] **3.1** En `cmd/launcher/main.go`: llamar `shell/security.Load("security/")` al inicio; para cada agente llamar `security.ResolveACL(cfg.Agent.ID, policy)` y pasar la `acl.ACL` resultante a `agents.New()` +- [ ] **3.2** En `agents/runtime.go`: añadir campo `acl acl.ACL` en `Agent{}`. Extender `agents.New()` para aceptar `acl.ACL` como parámetro adicional (o via `Option`). Usar `a.acl.CanDo()` en `shouldHandle()` y en evaluación de permisos de comandos/tools +- [ ] **3.3** En `shell/matrix/listener.go`: eliminar el chequeo de `AllowedUsers` (líneas 285-301 aprox.); el control de acceso ahora está en runtime via `acl.ACL` +- [ ] **3.4** En `internal/config/schema.go`: deprecar campos `security.roles` (añadir comentario `// Deprecated: usar security/ centralizado`) y `matrix.filters.allowed_users` (mismo comentario). No eliminar todavía — backward compat. +- [ ] **3.5** Actualizar `agents/assistant-bot/config.yaml` y `agents/asistente-2/config.yaml`: eliminar bloques `security.roles` y `matrix.filters.allowed_users` (ahora gestionados centralmente) +- [ ] **3.6** Actualizar `security/permissions.yaml` con los permisos reales de los agentes actuales (extraídos de sus configs antes de borrarlos) + +### Fase 4: Tests de integración + +- [ ] **4.1** `go build -tags goolm ./...` compila sin errores +- [ ] **4.2** `go test -tags goolm ./pkg/security/...` pasa +- [ ] **4.3** `go test -tags goolm ./shell/security/...` pasa +- [ ] **4.4** `go test -tags goolm ./...` pasa completo (sin romper tests existentes de pkg/acl) + +### Fase 5: Cleanup y docs + +- [ ] **5.1** Actualizar `docs/security.md` — documentar el sistema de grupos, estructura de los 3 YAML, campos disponibles en cada uno, cómo se resuelven las ACLs +- [ ] **5.2** Actualizar `CLAUDE.md` — añadir `security/` en la sección de estructura del proyecto +- [ ] **5.3** Añadir `.gitignore` entry si aplica (los YAML de `security/` SÍ se commitean — son config, no secrets) +- [ ] **5.4** Evaluar si eliminar definitivamente los campos deprecated del schema en este issue o dejarlo para un issue de limpieza posterior + +--- + +## Desglose multi-issue + +Este issue se implementa en 3 sub-issues independientes. + +| Sub-issue | Rama | Alcance | Estado | +|-----------|------|---------|--------| +| 0024a-security-types | issue/0024a-security-types | pkg/security/ tipos puros + resolver + tests | pendiente | +| 0024b-security-loader | issue/0024b-security-loader | security/ YAML files + shell/security/ loader + tests | pendiente | +| 0024c-security-integration | issue/0024c-security-integration | Wiring en launcher+runtime, cleanup config schema, update agent configs, docs | pendiente | + +### Feature flag + +Nombre: `centralized-security-groups` +Se activa en el último sub-issue (0024c) una vez que todos los agentes usan la política centralizada y se han eliminado los controles per-agente. + +### Progreso por tarea + +- [ ] **1.1** UserGroup, AgentGroup types — 0024a +- [ ] **1.2** Permission, AgentPolicy, SecurityPolicy types — 0024a +- [ ] **1.3** ResolveACL() function — 0024a +- [ ] **1.4** Wildcard support — 0024a +- [ ] **1.5** Tests pkg/security/ — 0024a +- [ ] **2.1** security/user-groups.yaml — 0024b +- [ ] **2.2** security/agent-groups.yaml — 0024b +- [ ] **2.3** security/permissions.yaml — 0024b +- [ ] **2.4** shell/security/loader.go — 0024b +- [ ] **2.5** Tests shell/security/ — 0024b +- [ ] **3.1** Launcher wiring — 0024c +- [ ] **3.2** Runtime ACL field + New() — 0024c +- [ ] **3.3** Remove AllowedUsers from listener — 0024c +- [ ] **3.4** Deprecar campos schema — 0024c +- [ ] **3.5** Update agent configs — 0024c +- [ ] **3.6** Populate permissions.yaml con datos reales — 0024c +- [ ] **4.1–4.4** Tests completos — 0024c +- [ ] **5.1–5.4** Cleanup y docs — 0024c + +--- + +## Ejemplo de uso + +**Estructura de archivos resultante:** +``` +security/ + user-groups.yaml + agent-groups.yaml + permissions.yaml +``` + +**security/user-groups.yaml:** +```yaml +groups: + admins: + members: + - "@alice:matrix-af2f3d.organic-machine.com" + - "@bob:matrix-af2f3d.organic-machine.com" + developers: + members: + - "@carol:matrix-af2f3d.organic-machine.com" + everyone: + members: ["*"] +``` + +**security/agent-groups.yaml:** +```yaml +groups: + assistants: + agents: + - assistant-bot + - asistente-2 + all: + agents: ["*"] +``` + +**security/permissions.yaml:** +```yaml +policies: + - agent_group: all + permissions: + - user_group: admins + actions: ["*"] + - user_group: developers + actions: ["ask", "command:help", "command:ping", "tool:*"] + - user_group: everyone + actions: ["ask"] +``` + +**Resultado:** Al arrancar, el launcher lee `security/`, resuelve la ACL de cada agente, y se la inyecta. Los agentes ya no tienen `security.roles` ni `allowed_users` en su config individual. Para dar permisos a un nuevo usuario en todos los agentes, basta editar `security/user-groups.yaml`. + +## Decisiones de diseño + +- **Reutilizar pkg/acl/ como motor**: `pkg/security/` no reemplaza `pkg/acl/`, lo usa. `ResolveACL()` produce `acl.ACL` que los agentes ya saben consumir. Mínimo cambio en runtime. +- **3 YAML separados vs 1 solo archivo**: separar grupos de usuarios, grupos de agentes, y permisos mantiene cada archivo enfocado. Los grupos son estables; los permisos cambian más frecuentemente. +- **Backward compat en schema**: deprecar pero no eliminar `security.roles` y `allowed_users` en 0024c. Eliminarlos definitivamente sería un issue de limpieza posterior. +- **Loader devuelve policy vacía si no existe security/**: no rompe agentes existentes si el directorio no existe. La ACL vacía equivale a "sin restricciones" (comportamiento actual). +- **ACL inyectada via parámetro en agents.New()**: alternativa a `Option{}` para mantener la firma explícita. Más simple y sin abstracción innecesaria. + +## Prerequisitos + +- `pkg/acl/` funcionando (completado en issue 0010) +- Agentes compilando con `-tags goolm` (ya funciona) + +## Riesgos + +- **Permisos actuales en config.yaml**: antes de eliminar `security.roles` de los configs de agente, leer y migrar todos los roles a `security/permissions.yaml`. Si se olvida alguno, el agente queda sin restricciones o con más acceso del esperado. Mitigación: hacer la migración explícitamente en tarea 3.6 antes de borrar en 3.5. +- **Orden de carga en launcher**: si el loader falla, los agentes arrancan sin ACL (acceso abierto). Mitigación: loguear WARNING claro en ese caso; considerar modo estricto (fail-fast) como opción de config futura. +- **acl.FromRoles() API**: verificar que `pkg/acl/` expone una función que acepte `[]acl.Role` directamente (no solo `map[string]RoleDef`). Si no existe, añadirla en 0024a. diff --git a/dev/issues/0024c-security-integration.md b/dev/issues/0024c-security-integration.md new file mode 100644 index 0000000..763b060 --- /dev/null +++ b/dev/issues/0024c-security-integration.md @@ -0,0 +1,109 @@ +# 0024c — Security integration: wiring, cleanup config, docs + +> Parte c del issue [0024-centralized-security-groups.md](0024-centralized-security-groups.md). +> Requiere 0024a y 0024b completados. + +## Objetivo + +Conectar el sistema centralizado de seguridad al launcher y al runtime. Eliminar los controles per-agente (`security.roles`, `matrix.filters.allowed_users`) de los configs de agente. Activar el feature flag. Actualizar docs. + +## Contexto + +- `pkg/security/` y `shell/security/` ya existen (0024a, 0024b). +- `agents/runtime.go` ya tiene un campo `acl acl.ACL` (añadido en issue 0010). Verificar si `agents.New()` lo acepta como parámetro o si necesita extenderse. +- `shell/matrix/listener.go` tiene checks de `AllowedUsers` que se eliminan (el ACL del runtime los reemplaza). +- `internal/config/schema.go` tiene `security.roles` (lines ~290-315) y `matrix.filters.allowed_users` (line ~230) que se deprecan. + +## Arquitectura + +``` +cmd/launcher/main.go MODIFIED +agents/runtime.go MODIFIED +shell/matrix/listener.go MODIFIED +internal/config/schema.go MODIFIED +agents/assistant-bot/config.yaml MODIFIED +agents/asistente-2/config.yaml MODIFIED +dev/feature_flags.json MODIFIED +docs/security.md MODIFIED +CLAUDE.md MODIFIED +``` + +### Patron pure core / impure shell + +- `cmd/launcher/` — **impuro**: carga la policy, resuelve ACL, inyecta en `Agent{}` +- `agents/runtime.go` — **composición**: recibe `acl.ACL` pre-resuelta + +## Tareas + +### Fase 1: Migrar permisos existentes + +- [ ] **1.1** Leer los bloques `security.roles` de `agents/assistant-bot/config.yaml` y `agents/asistente-2/config.yaml` y migrarlos a `security/permissions.yaml` +- [ ] **1.2** Leer `matrix.filters.allowed_users` de ambos configs y añadir esos usuarios a los grupos correspondientes en `security/user-groups.yaml` +- [ ] **1.3** Verificar que `security/permissions.yaml` captura todos los permisos existentes antes de eliminar los bloques per-agente + +### Fase 2: Wiring en launcher y runtime + +- [ ] **2.1** En `cmd/launcher/main.go`: añadir `shellsecurity.Load("security/")` al inicio del proceso de arranque. Si devuelve error, loguear WARN y continuar con policy vacía (no fail-fast — comportamiento conservador) +- [ ] **2.2** En `cmd/launcher/main.go`: para cada agente, llamar `security.ResolveACL(cfg.Agent.ID, policy)` y pasar la `acl.ACL` resultante a `agents.New()`. Loguear a nivel DEBUG cuántos roles se resolvieron para el agente. +- [ ] **2.3** En `agents/runtime.go`: verificar/añadir que `agents.New()` acepta `acl.ACL` como parámetro. Si ya existe el campo `acl` en `Agent{}`, adaptar la firma de `New()`. Si no existe, añadir campo y lógica de `CanDo()` en `shouldHandle()`. +- [ ] **2.4** En `agents/runtime.go`: cuando `a.acl.Empty()` es true (policy vacía), el comportamiento es "sin restricciones" (igual que antes). Cuando no está vacía, `shouldHandle()` verifica `a.acl.CanDo(senderID, "ask")` para mensajes y `a.acl.CanDo(senderID, "command:"+cmd)` para comandos. + +### Fase 3: Limpiar listener y config + +- [ ] **3.1** En `shell/matrix/listener.go`: eliminar el bloque de chequeo de `AllowedUsers` en `shouldHandle()` (líneas ~285-301). El control de acceso ahora lo hace el runtime. +- [ ] **3.2** En `shell/matrix/listener.go`: eliminar el invite gating basado en `AllowedUsers` (líneas ~105-119). Las invitaciones se aceptan siempre; el ACL se aplica cuando el usuario habla. +- [ ] **3.3** En `internal/config/schema.go`: añadir comentario `// Deprecated: use security/ centralized groups instead` sobre el campo `security.roles` y sobre `matrix.filters.allowed_users`. No eliminar el campo (backward compat temporal). +- [ ] **3.4** En `agents/assistant-bot/config.yaml`: eliminar bloque `security.roles` y campo `allowed_users` +- [ ] **3.5** En `agents/asistente-2/config.yaml`: eliminar bloque `security.roles` y campo `allowed_users` + +### Fase 4: Activar feature flag + +- [ ] **4.1** En `dev/feature_flags.json`: añadir entrada: + ```json + "centralized-security-groups": { + "enabled": true, + "issue": "0024", + "description": "Sistema centralizado de grupos de usuarios y agentes para control de acceso", + "added": "2026-03-08" + } + ``` + +### Fase 5: Tests + +- [ ] **5.1** `go build -tags goolm ./...` compila sin errores +- [ ] **5.2** `go test -tags goolm ./...` pasa completo +- [ ] **5.3** Arrancar el launcher localmente y verificar en logs: `"security policy loaded"`, `"resolved ACL for agent"` a nivel DEBUG/INFO +- [ ] **5.4** Verificar que un usuario listado en `admins` puede ejecutar comandos y tools +- [ ] **5.5** Verificar que un usuario no listado solo puede hacer `ask` (si la policy lo define así) + +### Fase 6: Docs y cleanup + +- [ ] **6.1** Actualizar `docs/security.md`: añadir sección "Sistema de grupos centralizados" con estructura de los 3 YAML, campos disponibles, ejemplos, y cómo se resuelven las ACLs. Marcar `security.roles` y `allowed_users` como deprecated. +- [ ] **6.2** Actualizar `CLAUDE.md`: añadir `security/` en la sección de estructura del proyecto +- [ ] **6.3** Cerrar issue 0024: mover `dev/issues/0024-centralized-security-groups.md` y sub-issues a `dev/issues/completed/` + +## Ejemplo de uso + +Flujo completo en producción: +``` +1. Editar security/user-groups.yaml — añadir @newuser al grupo "developers" +2. Reiniciar launcher (o esperar hot-reload si aplica) +3. @newuser puede hablar con todos los agentes según los permisos del grupo "developers" + Sin tocar ningún config.yaml de agente individual. +``` + +## Decisiones de diseño + +- **No fail-fast en loader**: si `security/` no existe o hay error de parseo, el launcher arranca con ACL vacía (sin restricciones). Preferible a que todos los agentes fallen por un typo en YAML. Se loguea WARN visible. +- **Eliminar invite gating**: el listener ya no filtra invites por AllowedUsers. El control ocurre cuando el usuario intenta interactuar. Más simple y consistente. +- **Deprecated pero no eliminado del schema**: los campos `security.roles` y `allowed_users` permanecen en el schema para no romper configs externos. Se eliminarán en un issue de limpieza posterior (0025 o similar). + +## Prerequisitos + +- 0024a completado +- 0024b completado + +## Riesgos + +- **Agentes sin permisos si security/permissions.yaml está vacío**: si se eliminan los bloques per-agente antes de migrar a permissions.yaml, los agentes quedan abiertos a todos. Mitigación: hacer la migración (tarea 1.1-1.3) ANTES de eliminar los bloques (tarea 3.4-3.5). +- **Firma de agents.New() cambia**: puede requerir actualizar tests existentes del runtime. Verificar antes. diff --git a/dev/issues/README.md b/dev/issues/README.md index c26796d..5182250 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -33,6 +33,6 @@ afectados y notas de implementacion. | 23 | Seccion de tests en dashboard | [0023-dashboard-tests.md](completed/0023-dashboard-tests.md) | completado | | 24 | Grupos y permisos centralizados | [0024-centralized-security-groups.md](0024-centralized-security-groups.md) | pendiente | | 24a | Security types: pkg/security/ | [0024a-security-types.md](completed/0024a-security-types.md) | completado | -| 24b | Security loader: shell/security/ | [0024b-security-loader.md](0024b-security-loader.md) | pendiente | +| 24b | Security loader: shell/security/ | [0024b-security-loader.md](completed/0024b-security-loader.md) | completado | | 24c | Security integration + cleanup | [0024c-security-integration.md](0024c-security-integration.md) | pendiente | | 25 | Catálogo cron + scaffolder | [0025-cron-scaffolder.md](completed/0025-cron-scaffolder.md) | completado | diff --git a/dev/issues/completed/0024b-security-loader.md b/dev/issues/completed/0024b-security-loader.md new file mode 100644 index 0000000..6d3a78f --- /dev/null +++ b/dev/issues/completed/0024b-security-loader.md @@ -0,0 +1,123 @@ +# 0024b — Security loader: security/ YAML files + shell/security/ loader + +> Parte b del issue [0024-centralized-security-groups.md](0024-centralized-security-groups.md). +> Requiere 0024a (pkg/security/ tipos). + +## Objetivo + +Crear la carpeta `security/` en la raíz del proyecto con los YAML de grupos de usuarios, grupos de agentes y permisos. Crear el loader impuro `shell/security/loader.go` que los lee y devuelve un `security.SecurityPolicy`. + +## Contexto + +- `pkg/security/` ya existe (0024a). Este sub-issue añade la capa de persistencia (YAML) y el loader. +- Los YAML de `security/` se commitean al repositorio — son configuración de acceso, no secrets. +- El código se mergea con feature flag = false (loader creado pero no usado todavía). + +## Arquitectura + +``` +security/ NEW — en raíz del proyecto + user-groups.yaml NEW + agent-groups.yaml NEW + permissions.yaml NEW + +shell/security/ NEW + loader.go NEW + loader_test.go NEW +``` + +### Patron pure core / impure shell + +- `security/*.yaml` — datos de configuración (no código) +- `shell/security/loader.go` — **impuro**: lee filesystem, parsea YAML, construye `security.SecurityPolicy` + +## Tareas + +### Fase 1: YAML files + +- [ ] **1.1** Crear `security/user-groups.yaml`: + ```yaml + # Grupos de usuarios del sistema + # Members: lista de Matrix user IDs, o "*" para todos los usuarios + groups: + admins: + members: [] # rellenar con los administradores reales + everyone: + members: ["*"] + ``` +- [ ] **1.2** Crear `security/agent-groups.yaml`: + ```yaml + # Grupos de agentes del sistema + # Agents: lista de agent IDs (del campo agent.id en config.yaml), o "*" para todos + groups: + assistants: + agents: + - assistant-bot + - asistente-2 + all: + agents: ["*"] + ``` +- [ ] **1.3** Crear `security/permissions.yaml`: + ```yaml + # Políticas de permisos: para cada grupo de agentes, qué acciones tiene cada grupo de usuarios + # Actions: "*" = todo, "ask" = chat libre, "command:" = comandos, "tool:" = tools + policies: + - agent_group: all + permissions: + - user_group: admins + actions: ["*"] + - user_group: everyone + actions: ["ask"] + ``` + +### Fase 2: Shell loader + +- [ ] **2.1** Crear `shell/security/loader.go` con función `Load(dir string) (security.SecurityPolicy, error)`: + - Lee `/user-groups.yaml` → `[]security.UserGroup` + - Lee `/agent-groups.yaml` → `[]security.AgentGroup` + - Lee `/permissions.yaml` → `[]security.AgentPolicy` + - Si el directorio no existe o está vacío: devuelve `security.SecurityPolicy{}` sin error (backward compat) + - Si un archivo no existe individualmente: ese campo queda vacío (no es error) + - Si el YAML es inválido: devuelve error con mensaje claro indicando qué archivo falló +- [ ] **2.2** Definir structs YAML intermedios (solo para parseo) distintos de los tipos puros de `pkg/security/`. Convertir tras parsear. Esto mantiene `pkg/security/` independiente de `gopkg.in/yaml.v3`. + +### Fase 3: Tests del loader + +- [ ] **3.1** Test: directorio inexistente → policy vacía, sin error +- [ ] **3.2** Test: directorio vacío (sin YAML) → policy vacía, sin error +- [ ] **3.3** Test: los 3 YAML válidos → policy con todos los campos +- [ ] **3.4** Test: solo `user-groups.yaml` presente → user groups poblados, resto vacío +- [ ] **3.5** Test: YAML malformado → error con nombre de archivo en el mensaje +- [ ] **3.6** Test: `user_group: "*"` y `agent: ["*"]` parseados correctamente como strings literales + +### Fase 4: Cleanup + +- [ ] **4.1** `go build -tags goolm ./...` compila +- [ ] **4.2** `go test -tags goolm ./shell/security/...` pasa +- [ ] **4.3** `go test -tags goolm ./...` pasa completo + +## Ejemplo de uso + +```go +// En el launcher (todavía no wired — eso es 0024c) +policy, err := shellsecurity.Load("security/") +if err != nil { + log.Fatal("error loading security policy", err) +} +// policy.UserGroups, policy.AgentGroups, policy.Policies disponibles +acl := security.ResolveACL("assistant-bot", policy) +``` + +## Decisiones de diseño + +- **Structs YAML separados de los tipos puros**: `pkg/security/` no importa `gopkg.in/yaml.v3`. El loader usa tipos intermedios locales y convierte. Mantiene el core verdaderamente puro. +- **Directorio no existente = policy vacía**: no fuerza a crear los YAML si no se necesitan (ej: agentes puramente públicos). Backward compat con configuraciones existentes. +- **3 archivos separados**: cada uno puede editarse independientemente. Los grupos son más estables que los permisos. + +## Prerequisitos + +- 0024a completado (`pkg/security/` con tipos y `ResolveACL`) + +## Riesgos + +- **Typos en user IDs de YAML**: si un Matrix ID tiene un typo, el usuario no tendrá acceso. No hay validación de formato de ID en este issue — es aceptable para MVP. diff --git a/security/agent-groups.yaml b/security/agent-groups.yaml new file mode 100644 index 0000000..8287cc1 --- /dev/null +++ b/security/agent-groups.yaml @@ -0,0 +1,9 @@ +# Grupos de agentes del sistema +# Agents: lista de agent IDs (del campo agent.id en config.yaml), o "*" para todos +groups: + assistants: + agents: + - assistant-bot + - asistente-2 + all: + agents: ["*"] diff --git a/security/permissions.yaml b/security/permissions.yaml new file mode 100644 index 0000000..99650fb --- /dev/null +++ b/security/permissions.yaml @@ -0,0 +1,9 @@ +# Políticas de permisos: para cada grupo de agentes, qué acciones tiene cada grupo de usuarios +# Actions: "*" = todo, "ask" = chat libre, "command:" = comandos, "tool:" = tools +policies: + - agent_group: all + permissions: + - user_group: admins + actions: ["*"] + - user_group: everyone + actions: ["ask"] diff --git a/security/user-groups.yaml b/security/user-groups.yaml new file mode 100644 index 0000000..1a8ff46 --- /dev/null +++ b/security/user-groups.yaml @@ -0,0 +1,7 @@ +# Grupos de usuarios del sistema +# Members: lista de Matrix user IDs, o "*" para todos los usuarios +groups: + admins: + members: [] # rellenar con los administradores reales + everyone: + members: ["*"] diff --git a/shell/security/loader.go b/shell/security/loader.go new file mode 100644 index 0000000..e204638 --- /dev/null +++ b/shell/security/loader.go @@ -0,0 +1,148 @@ +// Package security provides the impure loader for security policy YAML files. +// It reads security/ directory files and returns a pure security.SecurityPolicy. +package security + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/enmanuel/agents/pkg/security" +) + +// --- YAML intermediate types (private, only for parsing) --- + +type yamlUserGroups struct { + Groups map[string]struct { + Members []string `yaml:"members"` + } `yaml:"groups"` +} + +type yamlAgentGroups struct { + Groups map[string]struct { + Agents []string `yaml:"agents"` + } `yaml:"groups"` +} + +type yamlPermissions struct { + Policies []struct { + AgentGroup string `yaml:"agent_group"` + Permissions []struct { + UserGroup string `yaml:"user_group"` + Actions []string `yaml:"actions"` + } `yaml:"permissions"` + } `yaml:"policies"` +} + +// Load reads the security YAML files from dir and returns a SecurityPolicy. +// If dir does not exist or is empty, returns an empty policy without error. +// If an individual file is missing, that section is left empty. +// If a YAML file is malformed, returns an error naming the file. +func Load(dir string) (security.SecurityPolicy, error) { + if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) { + return security.SecurityPolicy{}, nil + } + + userGroups, err := loadUserGroups(filepath.Join(dir, "user-groups.yaml")) + if err != nil { + return security.SecurityPolicy{}, err + } + + agentGroups, err := loadAgentGroups(filepath.Join(dir, "agent-groups.yaml")) + if err != nil { + return security.SecurityPolicy{}, err + } + + policies, err := loadPermissions(filepath.Join(dir, "permissions.yaml")) + if err != nil { + return security.SecurityPolicy{}, err + } + + return security.SecurityPolicy{ + UserGroups: userGroups, + AgentGroups: agentGroups, + Policies: policies, + }, nil +} + +func loadUserGroups(path string) ([]security.UserGroup, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("security: reading %s: %w", path, err) + } + + var raw yamlUserGroups + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("security: parsing %s: %w", path, err) + } + + groups := make([]security.UserGroup, 0, len(raw.Groups)) + for name, g := range raw.Groups { + groups = append(groups, security.UserGroup{ + Name: name, + Members: g.Members, + }) + } + return groups, nil +} + +func loadAgentGroups(path string) ([]security.AgentGroup, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("security: reading %s: %w", path, err) + } + + var raw yamlAgentGroups + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("security: parsing %s: %w", path, err) + } + + groups := make([]security.AgentGroup, 0, len(raw.Groups)) + for name, g := range raw.Groups { + groups = append(groups, security.AgentGroup{ + Name: name, + Agents: g.Agents, + }) + } + return groups, nil +} + +func loadPermissions(path string) ([]security.AgentPolicy, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("security: reading %s: %w", path, err) + } + + var raw yamlPermissions + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("security: parsing %s: %w", path, err) + } + + policies := make([]security.AgentPolicy, 0, len(raw.Policies)) + for _, p := range raw.Policies { + perms := make([]security.Permission, 0, len(p.Permissions)) + for _, perm := range p.Permissions { + perms = append(perms, security.Permission{ + UserGroup: perm.UserGroup, + Actions: perm.Actions, + }) + } + policies = append(policies, security.AgentPolicy{ + AgentGroup: p.AgentGroup, + Permissions: perms, + }) + } + return policies, nil +} diff --git a/shell/security/loader_test.go b/shell/security/loader_test.go new file mode 100644 index 0000000..fa6f747 --- /dev/null +++ b/shell/security/loader_test.go @@ -0,0 +1,189 @@ +package security_test + +import ( + "os" + "path/filepath" + "testing" + + shellsecurity "github.com/enmanuel/agents/shell/security" +) + +// writeFile is a helper that creates a file in dir with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("writeFile %s: %v", name, err) + } +} + +// --- Test 3.1: directorio inexistente → policy vacía, sin error --- + +func TestLoad_NonExistentDir(t *testing.T) { + policy, err := shellsecurity.Load("/tmp/does-not-exist-security-xyz") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(policy.UserGroups) != 0 || len(policy.AgentGroups) != 0 || len(policy.Policies) != 0 { + t.Errorf("expected empty policy, got: %+v", policy) + } +} + +// --- Test 3.2: directorio vacío (sin YAML) → policy vacía, sin error --- + +func TestLoad_EmptyDir(t *testing.T) { + dir := t.TempDir() + policy, err := shellsecurity.Load(dir) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(policy.UserGroups) != 0 || len(policy.AgentGroups) != 0 || len(policy.Policies) != 0 { + t.Errorf("expected empty policy, got: %+v", policy) + } +} + +// --- Test 3.3: los 3 YAML válidos → policy con todos los campos --- + +func TestLoad_AllFiles(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "user-groups.yaml", ` +groups: + admins: + members: ["@admin:example.com"] + everyone: + members: ["*"] +`) + writeFile(t, dir, "agent-groups.yaml", ` +groups: + assistants: + agents: + - assistant-bot + all: + agents: ["*"] +`) + writeFile(t, dir, "permissions.yaml", ` +policies: + - agent_group: all + permissions: + - user_group: admins + actions: ["*"] + - user_group: everyone + actions: ["ask"] +`) + + policy, err := shellsecurity.Load(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(policy.UserGroups) != 2 { + t.Errorf("expected 2 user groups, got %d", len(policy.UserGroups)) + } + if len(policy.AgentGroups) != 2 { + t.Errorf("expected 2 agent groups, got %d", len(policy.AgentGroups)) + } + if len(policy.Policies) != 1 { + t.Errorf("expected 1 policy, got %d", len(policy.Policies)) + } + if len(policy.Policies[0].Permissions) != 2 { + t.Errorf("expected 2 permissions, got %d", len(policy.Policies[0].Permissions)) + } +} + +// --- Test 3.4: solo user-groups.yaml → user groups poblados, resto vacío --- + +func TestLoad_OnlyUserGroups(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "user-groups.yaml", ` +groups: + admins: + members: ["@admin:example.com"] +`) + + policy, err := shellsecurity.Load(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(policy.UserGroups) != 1 { + t.Errorf("expected 1 user group, got %d", len(policy.UserGroups)) + } + if policy.UserGroups[0].Name != "admins" { + t.Errorf("expected group name 'admins', got %q", policy.UserGroups[0].Name) + } + if len(policy.AgentGroups) != 0 { + t.Errorf("expected no agent groups, got %d", len(policy.AgentGroups)) + } + if len(policy.Policies) != 0 { + t.Errorf("expected no policies, got %d", len(policy.Policies)) + } +} + +// --- Test 3.5: YAML malformado → error con nombre de archivo en el mensaje --- + +func TestLoad_MalformedYAML(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "user-groups.yaml", `this: is: not: valid: yaml: [`) + + _, err := shellsecurity.Load(dir) + if err == nil { + t.Fatal("expected error for malformed YAML, got nil") + } + if got := err.Error(); len(got) == 0 { + t.Fatal("error message is empty") + } + // Must mention the filename + if !containsString(err.Error(), "user-groups.yaml") { + t.Errorf("error message should contain filename, got: %s", err.Error()) + } +} + +// --- Test 3.6: "*" como string literal en members y agents --- + +func TestLoad_WildcardStrings(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "user-groups.yaml", ` +groups: + everyone: + members: ["*"] +`) + writeFile(t, dir, "agent-groups.yaml", ` +groups: + all: + agents: ["*"] +`) + + policy, err := shellsecurity.Load(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(policy.UserGroups) != 1 { + t.Fatalf("expected 1 user group, got %d", len(policy.UserGroups)) + } + if len(policy.UserGroups[0].Members) != 1 || policy.UserGroups[0].Members[0] != "*" { + t.Errorf("expected members=[\"*\"], got %v", policy.UserGroups[0].Members) + } + + if len(policy.AgentGroups) != 1 { + t.Fatalf("expected 1 agent group, got %d", len(policy.AgentGroups)) + } + if len(policy.AgentGroups[0].Agents) != 1 || policy.AgentGroups[0].Agents[0] != "*" { + t.Errorf("expected agents=[\"*\"], got %v", policy.AgentGroups[0].Agents) + } +} + +func containsString(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstr(s, sub)) +} + +func containsSubstr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +}