From e5270d1c4628ce04251616a6728aa2d9bf7b439b Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sat, 7 Mar 2026 02:21:25 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20mover=20tasks=2008-09=20a=20completed?= =?UTF-8?q?=20y=20a=C3=B1adir=20tasks=2010-11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se mueven las tareas completadas (08-knowledge_por_agente, 09-command_system) al directorio .claude/tasks/completed/ para mantener organizado el backlog. Se añaden nuevas tareas planificadas: - 10-access-control.md: control de acceso por roles - 11-markdown-rendering.md: renderizado de markdown en mensajes Co-Authored-By: Claude Opus 4.6 --- .claude/tasks/10-access-control.md | 253 ++++++++++++++++++ .claude/tasks/11-markdown-rendering.md | 79 ++++++ .../08-knowledge_por_agente.md | 0 .../{ => completed}/09-command_system.md | 0 4 files changed, 332 insertions(+) create mode 100644 .claude/tasks/10-access-control.md create mode 100644 .claude/tasks/11-markdown-rendering.md rename .claude/tasks/{ => completed}/08-knowledge_por_agente.md (100%) rename .claude/tasks/{ => completed}/09-command_system.md (100%) diff --git a/.claude/tasks/10-access-control.md b/.claude/tasks/10-access-control.md new file mode 100644 index 0000000..b77567c --- /dev/null +++ b/.claude/tasks/10-access-control.md @@ -0,0 +1,253 @@ +# Task 10 — Control de acceso a agentes por usuario + +## Objetivo + +Implementar un sistema de control de acceso que permita restringir qué usuarios pueden interactuar con cada agente, usando la infraestructura `SecurityCfg` ya existente (declarada pero no enforceada). + +## Contexto actual + +- `SecurityCfg` ya existe en `internal/config/schema.go` con `Roles` (map de rol -> users + actions) +- `FiltersCfg` ya tiene `ignore_users` (blocklist) y `min_power_level` +- `shouldHandle()` en `shell/matrix/listener.go` filtra por room y blocklist, pero NO por allowlist +- Auto-join de invites es incondicional (acepta cualquier invite) +- `MatchMinPowerLevel()` en `pkg/decision/engine.go` existe pero `powerLevel` siempre se pasa como 0 +- Los configs de agentes ya definen roles pero nadie los verifica: + ```yaml + security: + roles: + admin: + users: ["@admin:matrix-af2f3d.organic-machine.com"] + actions: ["*"] + user: + users: ["*"] + actions: ["ask", "help", "summarize"] + ``` + +## Problema + +- Cualquier usuario del homeserver (o federado) puede invitar a un bot y hablar con el +- No hay forma de restringir acceso por usuario — los bots son publicos +- Los roles configurados en `security.roles` no se verifican en ningun punto +- El auto-join acepta invites de cualquiera sin verificar permisos +- No hay distincion entre acciones permitidas por rol (admin vs user) + +## Diseno + +### Arquitectura (pure core / impure shell) + +``` +pkg/acl/ -> PURE: tipos AccessList, CheckAccess(), ExtractRole() +shell/matrix/listener.go -> IMPURE: aplica ACL en shouldHandle() y auto-join +agents/runtime.go -> composicion: pasa ACL al listener, verifica roles en comandos +``` + +### Modelo de acceso + +Tres niveles de control, cada uno incrementa la restriccion: + +1. **Nivel 1 — Allowlist de usuarios** (quien puede hablar con el bot) +2. **Nivel 2 — Invite gating** (quien puede invitar al bot a una sala) +3. **Nivel 3 — RBAC por accion** (quien puede ejecutar que comandos/acciones) + +### Nivel 1 — Allowlist en FiltersCfg + +Agregar `allowed_users` a `FiltersCfg`: + +```go +// internal/config/schema.go +type FiltersCfg struct { + // ... existente ... + AllowedUsers []string `yaml:"allowed_users"` // allowlist (vacio = todos) +} +``` + +Config YAML: +```yaml +matrix: + filters: + allowed_users: + - "@admin:matrix-af2f3d.organic-machine.com" + - "@enmanuel:matrix-af2f3d.organic-machine.com" + # vacio o ausente = sin restriccion (todos pueden hablar) +``` + +Verificacion en `shouldHandle()`: +```go +// Despues de los filtros existentes, antes de return true +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 + } +} +``` + +### Nivel 2 — Invite gating + +Modificar el handler de `StateMember` invite para verificar quien invita: + +```go +// shell/matrix/listener.go — en el handler de invites +if membership != event.MembershipInvite { + return +} + +// Verificar si el invitante esta autorizado +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) + // Opcion: leave room o simplemente no joinear + return + } +} + +// Auto-join (existente) +l.client.raw.JoinRoom(ctx, evt.RoomID.String(), "", nil) +``` + +### Nivel 3 — RBAC por accion (conectar SecurityCfg.Roles) + +#### Nuevo paquete `pkg/acl/` (puro) + +```go +// pkg/acl/types.go + +// Role representa un rol con sus usuarios y acciones permitidas. +type Role struct { + Name string + Users []string // Matrix user IDs, "*" = todos + Actions []string // acciones permitidas, "*" = todas +} + +// ACL contiene la lista de control de acceso resuelta. +type ACL struct { + Roles []Role +} +``` + +```go +// pkg/acl/check.go + +// FromConfig construye un ACL desde el mapa de roles del config. Puro. +func FromConfig(roles map[string]config.RoleCfg) ACL { ... } + +// RoleFor devuelve el nombre del rol con mayor privilegio para un userID. Puro. +// Prioridad: el primer rol especifico que matchee; si ninguno, busca "*". +// Si no hay match, retorna "" (sin rol = sin acceso si RBAC esta activo). +func (a ACL) RoleFor(userID string) string { ... } + +// CanDo verifica si un userID puede ejecutar una accion. Puro. +// Si no hay roles definidos, retorna true (sin RBAC = acceso libre). +// Si hay roles pero el usuario no tiene ninguno, retorna false. +func (a ACL) CanDo(userID string, action string) bool { ... } + +// AllowedUsers retorna la lista consolidada de todos los userIDs +// con al menos un rol (excluyendo "*"). Util para allowlist. Puro. +func (a ACL) AllowedUsers() []string { ... } +``` + +#### Integracion en runtime.go + +```go +// agents/runtime.go — en handleEvent, despues de evaluar el comando + +// Para comandos built-in, verificar accion "command:" +if handler, ok := a.commands[msgCtx.Command]; ok { + if !a.acl.CanDo(msgCtx.SenderID, "command:"+msgCtx.Command) { + a.matrix.SendText(ctx, roomID, "No tienes permisos para este comando.") + return + } + reply := handler(ctx, msgCtx) + // ... +} + +// Para tool commands, verificar accion "tool:" +// Para LLM fallback, verificar accion "ask" (o la que corresponda) +``` + +#### Mapeo de acciones + +| Accion config | Que protege | +|----------------|----------------------------------------------| +| `*` | Todo (wildcard) | +| `ask` | Hablar con el LLM (mensajes de texto libre) | +| `command:*` | Todos los comandos !xxx | +| `command:tool` | Ejecutar !tool | +| `command:clear`| Ejecutar !clear | +| `tool:*` | Todas las tools via LLM | +| `tool:ssh_command` | Tool SSH especifica | +| `help` | Comandos informativos (!help, !info, !status)| + +### Retrocompatibilidad + +- Si `allowed_users` esta vacio → sin restriccion (como ahora) +- Si `security.roles` esta vacio → sin RBAC (como ahora) +- El comportamiento por defecto NO cambia — todo sigue abierto a menos que se configure + +### Respuesta a usuarios no autorizados + +Dos estrategias configurables: + +1. **Silent** (default): ignorar mensajes de usuarios no autorizados (como si el bot no existiera) +2. **Explicit**: responder con "No tienes permisos para interactuar con este agente" + +```yaml +matrix: + filters: + allowed_users: [...] + unauthorized_response: "silent" # silent | explicit +``` + +## Tareas de implementacion + +### Fase 1 — Allowlist basica (Nivel 1) +- [ ] Agregar `AllowedUsers []string` a `FiltersCfg` en `internal/config/schema.go` +- [ ] Agregar `UnauthorizedResponse string` a `FiltersCfg` (`silent` | `explicit`) +- [ ] Implementar check de allowlist en `shouldHandle()` de `shell/matrix/listener.go` +- [ ] Si `unauthorized_response: explicit`, responder antes de retornar false +- [ ] Tests: shouldHandle con allowlist vacia (pasa todo), con lista (filtra) + +### Fase 2 — Invite gating (Nivel 2) +- [ ] Modificar handler de `StateMember` invite en listener.go +- [ ] Verificar invitante contra `allowed_users` antes de auto-join +- [ ] Si no autorizado: no joinear (y opcionalmente leave/reject) +- [ ] Log de invites rechazados + +### Fase 3 — RBAC puro (Nivel 3) +- [ ] Crear `pkg/acl/types.go` — tipos Role, ACL +- [ ] Crear `pkg/acl/check.go` — FromConfig, RoleFor, CanDo, AllowedUsers +- [ ] Crear `pkg/acl/check_test.go` — tests exhaustivos del ACL puro +- [ ] Tests: wildcard "*" en users, wildcard "*" en actions, sin roles, multiples roles + +### Fase 4 — Conectar RBAC al runtime +- [ ] Construir ACL en `agents/runtime.go` New() desde `cfg.Security.Roles` +- [ ] Verificar permisos antes de ejecutar comandos built-in +- [ ] Verificar permisos antes de ejecutar tools (via LLM y via !tool) +- [ ] Verificar permiso "ask" antes de enviar al LLM +- [ ] Respuesta de "sin permisos" respetuosa cuando se deniega + +### Fase 5 — Config y documentacion +- [ ] Actualizar configs de assistant-bot y asistente-2 con ejemplo de allowed_users +- [ ] Documentar en `docs/creating-agents.md` la seccion de control de acceso +- [ ] Verificar que agentes sin security config siguen funcionando (retrocompat) + +### Fase 6 (futura) — Extensiones +- [ ] Audit log: registrar intentos de acceso denegados en audit log +- [ ] Patron glob en users: `@*:matrix-af2f3d.organic-machine.com` (solo usuarios locales) +- [ ] Rate limiting por rol (admin sin limite, user con rate limit) +- [ ] Comando `!acl` para admins: ver roles activos, verificar permisos de un usuario diff --git a/.claude/tasks/11-markdown-rendering.md b/.claude/tasks/11-markdown-rendering.md new file mode 100644 index 0000000..ebd65e0 --- /dev/null +++ b/.claude/tasks/11-markdown-rendering.md @@ -0,0 +1,79 @@ +# Tarea 11 — Renderizar mensajes como Markdown en Matrix + +## Problema + +Todos los mensajes de los agentes (respuestas LLM, comandos, errores) se envían como texto plano +via `SendText()`. Matrix soporta mensajes con `format: org.matrix.custom.html` + `formatted_body` +para renderizar Markdown (negrita, código, listas, etc.) en clientes como Element. + +Existe un `SendMarkdown()` en `shell/matrix/client.go` pero tiene dos problemas: +1. Solo se usa en un único lugar (`runtime.go:617` — notificación de tool use). +2. No convierte Markdown a HTML: pone el markdown crudo en `FormattedBody`, que Matrix espera como HTML. + +## Alcance + +### 1. Añadir conversión Markdown → HTML (`shell/matrix/client.go`) + +- Añadir dependencia `github.com/yuin/goldmark` (parser Markdown → HTML estándar, muy usado en Go). +- Corregir `SendMarkdown()` para que convierta el body de Markdown a HTML antes de ponerlo en `FormattedBody`. +- `Body` queda como texto plano (fallback para clientes que no soportan HTML) — se puede dejar el markdown crudo ahí, que es lo estándar en Matrix. + +```go +func (c *Client) SendMarkdown(ctx context.Context, roomID, markdown string) error { + html := mdToHTML(markdown) // nueva función interna + content := event.MessageEventContent{ + MsgType: event.MsgText, + Body: markdown, + Format: event.FormatHTML, + FormattedBody: html, + } + _, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) + return err +} +``` + +### 2. Cambiar la interfaz `MatrixSender` para exponer `SendMarkdown` + +- `shell/effects/runner.go`: añadir `SendMarkdown(ctx, roomID, text) error` a la interfaz `MatrixSender`. +- `tools/matrix.go`: añadir `SendMarkdown` a la interfaz `MatrixToolSender` (o como se llame). + +### 3. Cambiar todos los call sites de `SendText` → `SendMarkdown` + +Puntos a cambiar: + +| Archivo | Línea(s) | Contexto | +|---------|----------|----------| +| `agents/runtime.go:394` | Respuesta de tarea orquestada | `SendText → SendMarkdown` | +| `agents/runtime.go:456` | Reply LLM (loop) | `SendText → SendMarkdown` | +| `agents/runtime.go:462` | Reply LLM (fallback) | `SendText → SendMarkdown` | +| `shell/effects/runner.go:68` | Runner.executeOne (ActionKindReply) | `SendText → SendMarkdown` | +| `agents/runtime.go:456` | Comando ejecutado (!xxx) | `SendText → SendMarkdown` | +| `agents/runtime.go:462` | Comando desconocido | `SendText → SendMarkdown` | + +### 4. Mantener `SendText` para uso interno/futuro + +No eliminar `SendText`, solo dejar de usarlo como canal principal de respuesta. +Podría ser útil para mensajes que realmente no necesitan formato (logs internos, debugging). + +### 5. Actualizar interfaz en tests/mocks + +Cualquier mock de `MatrixSender` que exista en tests necesitará el método `SendMarkdown`. + +## Tareas ordenadas + +- [ ] `go get github.com/yuin/goldmark` +- [ ] Crear función `mdToHTML(md string) string` en `shell/matrix/` (usa goldmark) +- [ ] Corregir `SendMarkdown()` para usar `mdToHTML` +- [ ] Añadir `SendMarkdown` a la interfaz `MatrixSender` en `shell/effects/runner.go` +- [ ] Cambiar `runner.executeOne` (ActionKindReply) de `SendText` → `SendMarkdown` +- [ ] Cambiar `runtime.go` — respuesta de comandos (!xxx) a `SendMarkdown` +- [ ] Cambiar `runtime.go` — respuesta de tarea orquestada a `SendMarkdown` +- [ ] Actualizar interfaz en `tools/matrix.go` si aplica +- [ ] Actualizar mocks en tests +- [ ] Test manual: enviar mensaje al bot y verificar que Element renderiza markdown + +## Notas + +- goldmark es safe por defecto (escapa HTML peligroso) — no hay riesgo XSS. +- El `Body` del evento Matrix queda como markdown crudo — esto es correcto según la spec de Matrix (es el fallback plaintext). +- Los mensajes de error simples ("Comando desconocido: !foo") también pasan por `SendMarkdown` — no pasa nada, goldmark los deja como `

texto

` sin más. diff --git a/.claude/tasks/08-knowledge_por_agente.md b/.claude/tasks/completed/08-knowledge_por_agente.md similarity index 100% rename from .claude/tasks/08-knowledge_por_agente.md rename to .claude/tasks/completed/08-knowledge_por_agente.md diff --git a/.claude/tasks/09-command_system.md b/.claude/tasks/completed/09-command_system.md similarity index 100% rename from .claude/tasks/09-command_system.md rename to .claude/tasks/completed/09-command_system.md