docs: mover tasks 08-09 a completed y añadir tasks 10-11
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<name>"
|
||||
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:<name>"
|
||||
// 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
|
||||
@@ -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 `<p>texto</p>` sin más.
|
||||
Reference in New Issue
Block a user