diff --git a/README.md b/README.md index 94b9607..40d2317 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,17 @@ Fuente de verdad operativa del VPS DAVLOS. - Fase 4 abierta y en pausa operativa. - La suboperación 4.2 quedó recuperada y no es bloqueo activo. - Sin secretos en este repositorio. -- OpenClaw MVP local desplegado y validado documentalmente en esta rama. +- OpenClaw ya no está solo en baseline MVP: el boundary opera en host con `inference-gateway`, broker restringido, Telegram persistente y helper readonly. +- La DAVLOS VPN Console ya actúa como CLI operativo del boundary con dashboard, broker/capacidades, seguridad guiada y diagnóstico. ## Objetivo actual Prioridad operativa vigente: -1. mantener el estado estable actual de `n8n` -2. consolidar trazabilidad mínima de Fase 4 -3. fijar baseline técnico y documental de `OpenClaw` e `inference-gateway` -4. revalidar tooling readonly antes de cualquier siguiente cambio +1. mantener estable el runtime actual de `n8n` y `OpenClaw` +2. consolidar la operación real de consola, broker y Telegram sin abrir superficie innecesaria +3. mantener el helper readonly y el control restringido como baseline seguro del boundary +4. mantener la documentación viva alineada con el estado real ya desplegado y con la operación vigente 5. mantener Fase 4 en pausa hasta nueva decisión operativa ## Estado de n8n @@ -44,6 +45,9 @@ Hechos confirmados en la documentación operativa actual: Checkpoint actual: - despliegue MVP local ejecutado y validado en host +- broker restringido operativo en host con policy viva y auditoría +- canal Telegram privado operativo como runtime persistente +- helper readonly host-side instalado para inspección segura de broker y Telegram desde consola - runtime materializado en host bajo: - `/opt/automation/agents/openclaw` - `/etc/davlos/secrets/openclaw` @@ -66,17 +70,59 @@ Checkpoint actual: - `docs/OPENCLAW_SECURITY_BOOTSTRAP_MVP.md` - `docs/OPENCLAW_HOST_SECRETS_CONTRACT_MVP.md` - `templates/openclaw/openclaw.json.example` -- observabilidad readonly integrada en consola: +- operación integrada actual en consola: + - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh overview` - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw` - - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-health` - - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-logs` -- todavía no existe en este baseline: - - broker restringido - - menú final de control con acciones de escritura - - integración Telegram - - chat operativo final - - acciones A/B/C/D - - allowlist real de egress aplicada en runtime + - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-capabilities` + - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-capabilities-audit` + - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-telegram` + - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-diagnostics` +- helper readonly host-side para visibilidad runtime: + - `/usr/local/sbin/davlos-openclaw-readonly` + - `/etc/sudoers.d/davlos-openclaw-readonly` +- ownership observado en runtime: + - `root` conserva `compose`, `broker`, `dropzone` y secretos + - `devops` posee `config`, `state` y `logs` + - este reparto debe tratarse como deliberado hasta que exista una decisión posterior respaldada por evidencia y rollback +- superficie operativa actual: + - dashboard de consola con estado de host, broker, Telegram y runtime + - consola reorganizada por runtime, broker, seguridad, evidencias y diagnóstico + - control guiado de capacidades con TTL y reset one-shot + - presets de seguridad en consola + - Telegram como canal corto de consulta, ejecución cerrada y modo conversacional controlado + - local-first en Telegram: frases conocidas siguen por reglas; el fallback LLM solo entra en `wake` cuando el matcher local no resuelve + - Gemini puede operar como fallback controlado en runtime mediante env seguro, sin alterar el perímetro `auth/policy/broker` +- límites que siguen vigentes: + - no hay UI web final de control; la operación principal sigue en consola + Telegram + - start/stop/restart no se exponen directamente desde la consola + - el hardening final de egress no se declara cerrado en este README + +## Distinción operativa obligatoria + +Para OpenClaw, este repositorio debe distinguir siempre entre tres planos: + +- documentación y diseño objetivo; +- plantillas y scripts de despliegue versionados; +- estado operativo real observado en host. + +Las plantillas del repo no deben leerse automáticamente como espejo exacto del runtime vivo. + +Ejemplos ya conocidos de drift que exigen prudencia: + +- el publish northbound observado del gateway es loopback-only en `127.0.0.1:18789`; +- `templates/openclaw/openclaw.json.example` mantiene `bind: "lan"` y hoy debe tratarse como drift contractual pendiente, no como permiso para corregir el runtime a ciegas; +- `scripts/agents/openclaw/30_first_local_deploy.sh` es despliegue real y no mero staging; +- el helper readonly versionado ya expone cinco modos, incluido `operational_logs_recent`. + +Mientras exista drift material repo/host: + +- no redeployar a ciegas; +- no sobrescribir runtime vivo con plantillas por reflejo; +- no asumir que una plantilla sensible ya representa el estado efectivo del VPS. + +La referencia documental explícita para este punto es: + +- `docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md` ## Documentos clave @@ -93,9 +139,16 @@ Checkpoint actual: - `runbooks/OPENCLAW_DEPLOY_MVP.md` - `docs/OPENCLAW_SECURITY_BOOTSTRAP_MVP.md` - `docs/OPENCLAW_HOST_SECRETS_CONTRACT_MVP.md` +- `docs/CONSOLE_OPENCLAW_CAPABILITIES_MVP.md` +- `docs/DAVLOS_VPN_CONSOLE_PRESENTATION_MVP.md` +- `docs/OPENCLAW_OPERATOR_FLOWS_MVP.md` +- `docs/TELEGRAM_OPENCLAW_RUNTIME_FINAL.md` +- `docs/TELEGRAM_OPENCLAW_CONVERSATIONAL_MVP.md` +- `docs/TELEGRAM_OPENCLAW_LLM_FALLBACK_PHASE_16_17.md` +- `docs/OPENCLAW_READONLY_HELPER_INSTALL.md` Nota: -Algunos documentos conservan contexto histórico y deben leerse con fecha y alcance. La verdad operativa actual de `n8n` queda reflejada en este `README`, en `evidence/FASE_4_ESTADO.md`, en `evidence/PHASE4_PAUSE_AND_4_2_RECOVERED_2026-03-31.md` y en las evidencias recientes de prechecks. La verdad actual de `OpenClaw` en este checkpoint es: MVP local desplegado, aislado y observable en modo readonly, pero todavía sin capa final de control restringido ni hardening completo de egress. +Algunos documentos conservan contexto histórico y deben leerse con fecha y alcance. La verdad operativa actual de `n8n` queda reflejada en este `README`, en `evidence/FASE_4_ESTADO.md`, en `evidence/PHASE4_PAUSE_AND_4_2_RECOVERED_2026-03-31.md` y en las evidencias recientes de prechecks. La verdad actual de `OpenClaw` en este checkpoint es: boundary operativo con `inference-gateway`, broker restringido con policy viva y auditoría, Telegram persistente como canal corto, modo conversacional controlado `local-first`, fallback LLM acotado a `wake` y helper readonly host-side para inspección segura desde consola. El hardening final de egress sigue documentado por fases y no se declara cerrado en este `README`. ## Regla base diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 1295e4c..432308f 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -13,7 +13,7 @@ No sustituye la evidencia operativa validada en runtime. - existe un único runtime de agente documentado y validado: `OpenClaw` - el servicio desplegado es `openclaw-gateway` - el runtime host-side vive bajo `/opt/automation/agents/openclaw` -- la ruta host-side de secretos reservada es `/etc/davlos/secrets/openclaw` +- la ruta host-side de secretos usada hoy es `/etc/davlos/secrets/openclaw` - la red usada es `agents_net` - el bind host publicado para el gateway es `127.0.0.1:18789` - OpenClaw consume inferencia por `http://172.22.0.1:11440/v1` @@ -21,8 +21,22 @@ No sustituye la evidencia operativa validada en runtime. - `inference-gateway` escucha solo en `127.0.0.1:11440` y `172.22.0.1:11440` - `inference-gateway` ya no responde por la IP pública del host - la reachability `agents_net -> 172.22.0.1:11440` quedó validada en runtime +- existe broker restringido operativo en host con policy viva y auditoría +- existe Telegram persistente operativo como canal corto, con degradación/intermitencia de polling ya observada +- existe helper readonly host-side y debe tratarse como vía preferente de observabilidad controlada - la consola `DAVLOS VPN Console` expone observabilidad readonly para `OpenClaw` e `inference-gateway` +## precedencia documental + +Para OpenClaw deben distinguirse siempre tres planos: + +- estado operativo real observado; +- plantillas y scripts versionados; +- diseño y documentación del repo. + +Este documento describe el contrato operativo vigente del repo, pero no sustituye evidencia de host. +Cuando exista drift material entre repo y runtime, debe prevalecer la evidencia observada y documentarse el gap antes de cualquier redeploy. + ## trust boundary operativa La zona de agentes actual se compone de dos piezas separadas: @@ -59,6 +73,10 @@ Motivo de la separación: - `no-new-privileges` - `cap_drop: ALL` - sin publicación en IP pública +- drift contractual conocido: + - el control northbound observado es loopback-only por publish host + - `templates/openclaw/openclaw.json.example` mantiene `bind: "lan"` + - este punto debe tratarse como drift contractual/documental pendiente y no corregirse a ciegas en runtime ### inference-gateway @@ -79,7 +97,18 @@ Motivo de la separación: - el repositorio no contiene secretos reales - `OPENCLAW_GATEWAY_TOKEN` sigue siendo el único secreto operativo mínimo del MVP local - hoy puede vivir en el `.env` root-owned del runtime de OpenClaw -- `/etc/davlos/secrets/openclaw` queda reservado para crecimiento posterior sin reestructurar mounts ni contrato host-side +- `/etc/davlos/secrets/openclaw` ya forma parte del runtime host-side efectivo + +### ownership observado del runtime + +- `root` conserva `compose`, `broker`, `dropzone` y secretos +- `devops` posee `config`, `state` y `logs` + +Juicio operativo: + +- este reparto debe tratarse como deliberado; +- no debe simplificarse a `chown -R` completo a `devops`; +- si hiciera falta más visibilidad operativa para `devops`, la vía preferente sigue siendo helper readonly o un mecanismo acotado equivalente. ## límites explícitos de acceso @@ -113,15 +142,17 @@ Motivo de la separación: - trust boundary separada respecto a `n8n`, NPM, WireGuard y PostgreSQL - mínimo privilegio en el contenedor - sin credenciales cloud necesarias en este MVP local +- broker restringido y Telegram ya forman parte del runtime operativo observado - cambios operativos posteriores deben ser pequeños, reversibles y con evidencia -- el siguiente tramo de evolución es broker restringido, no nuevas capacidades prematuras +- cualquier intervención posterior debe pasar antes por revisión explícita de drift repo/runtime ## riesgos residuales conocidos -- la allowlist real de egress para `agents_net` sigue pendiente +- el hardening final de egress sigue pendiente de cierre documental final - la imagen sigue fijada por tag revisado y no por digest operativo final - el `healthcheck` actual de OpenClaw es suficiente para MVP, pero no equivale a política final de liveness/readiness - existe drift operativo en UFW entre configuración declarada y reglas runtime cargadas; hoy no bloquea la operación, pero debe normalizarse antes de endurecimientos posteriores +- existe drift material entre documentación, plantillas y runtime observado; no debe redeployarse a ciegas mientras siga abierto ## superficie disponible hoy @@ -133,14 +164,13 @@ Motivo de la separación: ## lo que no debe darse por implementado -- broker restringido -- policy store - menú final de control de capacidades con escritura -- integración Telegram - chat operativo final - acciones A/B/C/D -- allowlist final de egress aplicada +- allowlist final de egress declarada como totalmente cerrada por toda la documentación - política final de secretos para proveedores externos +- resolución definitiva del drift entre publish loopback y `bind: "lan"` +- coincidencia exacta entre plantillas sensibles y runtime vivo ## documentos de referencia @@ -151,5 +181,6 @@ Motivo de la separación: - `docs/OPENCLAW_HOST_SECRETS_CONTRACT_MVP.md` - `runbooks/OPENCLAW_DEPLOY_MVP.md` - `runbooks/OPENCLAW_ROLLBACK_MVP.md` +- `docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md` - `docs/reports/OPENCLAW_BOUNDARY_RUNTIME_FIX_2026-04-01.md` - `docs/reports/OPENCLAW_AGENTS_NET_REACHABILITY_FIX_2026-04-01.md` diff --git a/docs/CONSOLE_OPENCLAW_CAPABILITIES_MVP.md b/docs/CONSOLE_OPENCLAW_CAPABILITIES_MVP.md index c17ca49..61c1ed2 100644 --- a/docs/CONSOLE_OPENCLAW_CAPABILITIES_MVP.md +++ b/docs/CONSOLE_OPENCLAW_CAPABILITIES_MVP.md @@ -2,23 +2,24 @@ ## objetivo -Documentar el uso del submenú de capacidades OpenClaw en la DAVLOS VPN Console, reutilizando la CLI del broker y la política viva existentes. +Documentar el uso del bloque `Broker y capacidades` de la DAVLOS VPN Console, reutilizando la CLI del broker, la policy viva y el helper readonly host-side cuando el runtime no es legible directamente. ## principio de integración La consola no edita JSON directamente. -Toda operación se apoya en: +Toda mutación se apoya en: - `scripts/agents/openclaw/restricted_operator/cli.py` -La consola solo actúa como front-end Bash de operador para: +La consola actúa como front-end Bash de operador para: - listar estado efectivo +- ver auditoría reciente +- ver catálogo de acciones - habilitar o deshabilitar acciones - habilitar una acción con TTL - resetear una acción `one_shot` -- ver auditoría reciente Las mutaciones no se aplican directamente desde Bash: @@ -26,17 +27,29 @@ Las mutaciones no se aplican directamente desde Bash: - la CLI valida ese `operator_id` contra la allowlist viva de operadores - si el operador no está autorizado, la mutación se rechaza y queda auditada +La inspección readonly del runtime intenta este orden: + +1. lectura directa del runtime +2. helper host-side `sudo -n /usr/local/sbin/davlos-openclaw-readonly` +3. fallback declarativo a la policy visible del repo + ## entrada al menú Desde la consola: -- `7) OpenClaw / inference-gateway` -- `5) Capacidades OpenClaw` +- `3) Broker y capacidades` +- `1) Estado efectivo` +- `2) Auditoría reciente` +- `3) Catálogo de acciones` +- `4) Control manual por acción` +- `5) Diagnóstico broker/runtime` También existen accesos directos: - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-capabilities` - `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-capabilities-audit` +- `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-actions` +- `bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-diagnostics` ## operaciones disponibles @@ -44,16 +57,41 @@ También existen accesos directos: Muestra por acción: -- `action_id` -- `enabled` +- `status` - `mode` +- `allowed` +- `permission` +- `action_id` - `expires_at` - `one_shot` -- `one_shot_consumed` -- `status` +- `consumed` - `reason` - `updated_by` +Cuando el runtime no es legible directamente pero el helper readonly está instalado, la consola usa: + +- `sudo -n /usr/local/sbin/davlos-openclaw-readonly broker_state_console` + +### ver auditoría reciente + +Llama a: + +- `cli.py audit-tail --lines 20` + +Si la CLI no puede leer el runtime por permisos y el helper readonly está instalado, la consola usa: + +- `sudo -n /usr/local/sbin/davlos-openclaw-readonly broker_audit_recent` + +### ver catálogo de acciones + +La consola muestra una ficha operativa por acción conocida con: + +- `label` +- `action_id` +- `permission` +- descripción corta +- badge visual `READONLY`, `RESTRICTED` o `CONTROL` + ### habilitar acción Pide: @@ -103,11 +141,17 @@ Llama a: - `cli.py reset-one-shot` -### ver auditoría reciente +### control manual por acción -Llama a: +En modo interactivo, la consola ya no obliga al operador a memorizar siempre `action_id`. -- `cli.py audit-tail --lines 20` +Puede seleccionar desde menú: + +- `action.webhook.trigger.v1` +- `action.dropzone.write.v1` +- `action.openclaw.restart.v1` + +o introducir un `action_id` manual cuando haga falta. ## trazabilidad mínima @@ -122,13 +166,15 @@ Los cambios de capacidades dejan al menos: La auditoría se conserva en el audit log del broker. +Cuando existe helper readonly, la consola puede leer esa auditoría y el estado efectivo sin abrir permisos generales sobre `/opt/automation`. + ## degradación segura Si la sesión no tiene: - acceso a la policy - acceso a Python -- permisos para escribir el state store +- permisos para leer o escribir el state store - un `operator_id` autorizado para mutaciones la consola: @@ -136,10 +182,12 @@ la consola: - muestra un mensaje corto y claro - no rompe otros menús - no intenta editar JSON manualmente +- si existe helper readonly, intenta leer runtime y auditoría por esa vía antes de degradar ## límites conocidos - no existe autenticación fuerte final -- no hay menú final de producto, solo menú de operador -- el restart de OpenClaw sigue fuera del alcance de esta fase -- el menú opera sobre la policy disponible en runtime o, si no existe, sobre el ejemplo versionado visible +- no hay UI web final de control; la operación sigue siendo consola + Telegram +- `action.openclaw.restart.v1` sigue siendo la acción más sensible y debe abrirse con TTL corto +- sin helper readonly o permisos equivalentes, la visibilidad del runtime activo puede degradarse +- el menú prioriza siempre policy/runtime real; el ejemplo versionado del repo solo es fallback declarativo diff --git a/docs/CONSOLE_TUI_MENU_WIP_README.md b/docs/CONSOLE_TUI_MENU_WIP_README.md new file mode 100644 index 0000000..7d2a357 --- /dev/null +++ b/docs/CONSOLE_TUI_MENU_WIP_README.md @@ -0,0 +1,86 @@ +# Console TUI Menu WIP + +Estado guardado para continuar el trabajo del menu TUI de `scripts/console/davlos-vpn-console.sh`. + +## Rama de trabajo + +- Rama actual: `codex/console-tui-navigation-wip-20260402` + +## Objetivo ya completado + +- Header superior refactorizado con panel verde, banner DAVLOS centrado y fallback compacto para terminales estrechas. +- Guardas seguras para `clear` en entornos no interactivos. +- Metricas reales en header: + - CPU con fallback a `/proc/stat` + - RAM con `free -m` + - Hora forzada a `Europe/Madrid` +- Menus interactivos con flechas `Up/Down` y `Enter` usando `read -rsn1`. +- Redibujado limitado a la zona del menu para no destruir el header. +- Fallback no interactivo mantenido para sesiones sin TTY. + +## Menus ya migrados a `interactive_menu` + +- Menu principal +- OpenClaw y Telegram +- Broker y capacidades +- Seguridad y control +- Diagnostico +- Broker / control manual +- Selector de accion mutante + +## Helpers nuevos relevantes + +- `panel_text_length` +- `panel_truncate_text` +- `panel_row` +- `panel_center_line` +- `panel_rule` +- `panel_metric_cpu` +- `panel_metric_ram` +- `panel_metric_vpn_nodes` +- `panel_terminal_width` +- `interactive_menu_render` +- `interactive_menu` +- `menu_choice_with_fallback` +- `inline_menu_choice_with_fallback` +- `init_menu_options` + +## Validacion ya hecha + +- `bash -n scripts/console/davlos-vpn-console.sh` +- `printf '9\n' | env -u TERM bash scripts/console/davlos-vpn-console.sh` +- Prueba TTY real del menu principal +- Prueba TTY real entrando en `Broker y capacidades` y volviendo con `Volver` + +## Comportamiento actual + +- En TTY: + - Se muestra cursor visual `❯` + - Soporta flechas `Up/Down` + - Soporta `j/k` como alias de navegacion + - `Enter` devuelve el id de opcion y ejecuta la rama existente +- Sin TTY: + - Se usa `show_*` + `read -r` como antes + +## Limitaciones conocidas + +- La seleccion actual siempre arranca en la primera opcion al volver a un menu. +- No hay soporte aun para `Home`, `End`, `Esc` o `q`. +- No hay memoria de ultima opcion por submenu. +- El cursor `❯` y algunos emojis del texto pueden depender del terminal del operador. + +## Proximo paso recomendado + +1. Persistir la opcion activa por menu para mejorar la navegacion repetida. +2. Anadir atajos `Esc` o `q` para volver atras sin tener que ir a `Volver`. +3. Introducir un modo ASCII-safe opcional para terminales con render Unicode pobre. +4. Conectar `Nodos VPN` a una fuente real de runtime cuando se defina la fuente de verdad. + +## Ficheros que pertenecen a este trabajo + +- `scripts/console/davlos-vpn-console.sh` +- `docs/CONSOLE_TUI_MENU_WIP_README.md` + +## Nota de alcance + +El arbol git contiene otros cambios no relacionados. Para este checkpoint solo deben staged/committearse los dos ficheros anteriores. diff --git a/docs/OPENCLAW_OPERATOR_FLOWS_MVP.md b/docs/OPENCLAW_OPERATOR_FLOWS_MVP.md index b31d0d9..3af8271 100644 --- a/docs/OPENCLAW_OPERATOR_FLOWS_MVP.md +++ b/docs/OPENCLAW_OPERATOR_FLOWS_MVP.md @@ -16,18 +16,21 @@ No introduce nuevas capacidades. Su objetivo es medir fricción real de operador Herramienta: - `bash scripts/console/davlos-vpn-console.sh openclaw-capabilities` +- `bash scripts/console/davlos-vpn-console.sh openclaw-diagnostics` Resultado: - la consola muestra estado efectivo legible - el operador ve `status`, `mode`, `allowed`, `permission` - cuando aplica, también ve `expires_at`, `one_shot` y `consumed` +- con el helper readonly instalado, la vista sale del runtime real aunque la sesión no pueda leer `/opt/automation` directamente Valor operativo: - sirve como vista rápida diaria del boundary de capacidades - no obliga a leer JSON ni a inspeccionar la policy a mano Fricción residual: -- si se quiere ver trazabilidad real, la plantilla de repo no trae eventos runtime; hay que mirar el audit log activo o validar sobre una policy/runtime temporal +- si se quiere ver trazabilidad real, hay que mirar el audit log activo; la plantilla del repo sigue siendo declarativa +- sin helper readonly o permisos equivalentes, la visibilidad del runtime puede degradarse ### 2. Habilitar una capacidad con TTL @@ -60,7 +63,7 @@ Valor operativo: Fricción residual: - el operador necesita entender que `one_shot` depende de estado runtime, no solo del JSON versionado -- si se mezcla plantilla declarativa con state store viejo, la lectura puede resultar confusa +- si no se lee el runtime real o se mezcla plantilla declarativa con state store viejo, la lectura puede resultar confusa ### 4. Ejecutar una acción permitida por Telegram @@ -85,14 +88,15 @@ Fricción residual: ## Qué Funciona Bien - La consola ya es suficiente para operar capacidades OpenClaw sin otra UI. +- El helper readonly reduce mucho la fricción entre policy declarativa y runtime real. - La policy viva con TTL y one-shot cubre el ciclo básico de apertura, consumo y cierre. - Telegram aporta valor como canal corto de consulta/ejecución. - La trazabilidad mínima es suficiente para una operación MVP disciplinada. ## Fricciones Reales Detectadas -- La diferencia entre policy declarada y runtime state no siempre es evidente para un operador nuevo. -- La auditoría desde plantilla de repo puede salir vacía aunque el sistema real tenga actividad. +- La diferencia conceptual entre policy declarada y runtime state sigue existiendo para un operador nuevo, aunque la consola la mitiga mejor si el helper readonly está activo. +- Sin helper readonly o permisos equivalentes, la auditoría y el estado real pueden degradarse aunque el sistema siga operativo. - Telegram es práctico para comandos breves, pero el formato de parámetros no escala bien a payloads más ricos. - Faltan algunas acciones operativas pequeñas si se quisiera ampliar uso diario, por ejemplo verificaciones o lecturas más específicas; eso no bloquea el MVP actual. @@ -111,6 +115,6 @@ La recomendación es: La siguiente inversión útil no es otro canal, sino una de estas dos opciones: - añadir una acción nueva y cerrada de alto valor operativo -- o mejorar la visibilidad de runtime activo frente a plantilla declarativa +- o ampliar la observabilidad y ergonomía sobre el runtime activo ya existente Mientras eso no sea necesario, el sistema actual ya soporta pruebas operativas reales con fricción razonable. diff --git a/docs/OPENCLAW_READONLY_HELPER_INSTALL.md b/docs/OPENCLAW_READONLY_HELPER_INSTALL.md new file mode 100644 index 0000000..2f99bea --- /dev/null +++ b/docs/OPENCLAW_READONLY_HELPER_INSTALL.md @@ -0,0 +1,113 @@ +# Instalación manual del helper readonly de OpenClaw + +## Objetivo + +Instalar un helper root-owned de solo lectura para que la consola DAVLOS pueda inspeccionar el runtime real de OpenClaw, el broker y Telegram sin conceder a `devops` acceso general a `/opt/automation` ni a `root`. + +Este helper debe tratarse como la vía preferente de observabilidad controlada cuando `devops` no pueda leer directamente el runtime activo. + +## Archivos preparados + +- [davlos-openclaw-readonly.sh](/opt/control-plane/templates/openclaw/davlos-openclaw-readonly.sh) +- [davlos-openclaw-readonly.sudoers](/opt/control-plane/templates/openclaw/davlos-openclaw-readonly.sudoers) + +## Qué expone el helper + +- `runtime_summary` +- `broker_state_console` +- `broker_audit_recent` +- `telegram_runtime_status` +- `operational_logs_recent` + +Los subcomandos son cerrados y sin parámetros libres. No dan shell, no aceptan rutas arbitrarias y no escriben en runtime. + +Nota de alcance: + +- el helper no sustituye validación de host; +- el helper reduce necesidad de abrir permisos generales sobre `/opt/automation`; +- no debe ampliarse a mutaciones. + +## Pasos exactos por root + +### 1. Instalar el helper en la ruta final + +```bash +sudo install -o root -g root -m 0750 \ + /opt/control-plane/templates/openclaw/davlos-openclaw-readonly.sh \ + /usr/local/sbin/davlos-openclaw-readonly +``` + +### 2. Instalar el sudoers restringido + +```bash +sudo install -o root -g root -m 0440 \ + /opt/control-plane/templates/openclaw/davlos-openclaw-readonly.sudoers \ + /etc/sudoers.d/davlos-openclaw-readonly +``` + +### 3. Validar sintaxis de sudoers + +```bash +sudo visudo -cf /etc/sudoers.d/davlos-openclaw-readonly +``` + +### 4. Validar permisos y ownership + +```bash +ls -l /usr/local/sbin/davlos-openclaw-readonly +ls -l /etc/sudoers.d/davlos-openclaw-readonly +``` + +Esperado: + +- helper: `root root` y modo `0750` +- sudoers: `root root` y modo `0440` + +### 5. Validar desde `devops` + +```bash +sudo -n -l +sudo -n /usr/local/sbin/davlos-openclaw-readonly runtime_summary +sudo -n /usr/local/sbin/davlos-openclaw-readonly broker_state_console +sudo -n /usr/local/sbin/davlos-openclaw-readonly broker_audit_recent +sudo -n /usr/local/sbin/davlos-openclaw-readonly telegram_runtime_status +sudo -n /usr/local/sbin/davlos-openclaw-readonly operational_logs_recent +``` + +## Integración esperada en la consola + +Después de instalarlo: + +- `openclaw-capabilities` debe poder mostrar el estado efectivo vivo vía helper si el state store directo no es legible. +- `openclaw-capabilities-audit` debe poder leer auditoría reciente vía helper. +- `openclaw-telegram` debe poder mostrar `telegram_runtime_status.json` vía helper. +- `openclaw-diagnostics` y vistas equivalentes deben poder reutilizar `operational_logs_recent` para logs operativos permitidos. +- `overview` y `openclaw-diagnostics` deben marcar el helper readonly como disponible. + +## relación con ownership y drift + +- el helper existe precisamente para evitar abrir acceso general de `devops` a `/opt/automation/agents/openclaw`; +- no debe usarse esta instalación como argumento para relajar ownership mixto del runtime; +- si existe drift material entre documentación, plantillas y runtime, no debe redeployarse a ciegas hasta revisarlo de forma explícita; +- el documento de referencia para ese punto es `docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md`. + +## No hacer + +- no añadir `devops` a grupos con acceso general a `/opt/automation` +- no dar sudo directo a scripts versionados del repo +- no permitir comodines en sudoers +- no ampliar este helper a acciones mutantes + +## Rollback + +```bash +sudo rm -f /etc/sudoers.d/davlos-openclaw-readonly +sudo rm -f /usr/local/sbin/davlos-openclaw-readonly +``` + +Comprobación: + +```bash +test ! -e /etc/sudoers.d/davlos-openclaw-readonly && echo SUDOERS_REMOVED +test ! -e /usr/local/sbin/davlos-openclaw-readonly && echo HELPER_REMOVED +``` diff --git a/docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md b/docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md new file mode 100644 index 0000000..53c7038 --- /dev/null +++ b/docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md @@ -0,0 +1,159 @@ +# OpenClaw Runtime Drift 2026-04-08 + +## propósito + +Dejar una referencia documental mínima y explícita sobre drift conocido entre: + +- diseño y documentación del repo; +- plantillas y scripts versionados; +- estado host observado en auditorías previas. + +Este documento no valida el host por sí solo. +Tampoco autoriza redeploy, rollback ni cambios de runtime. + +## alcance + +Este documento cubre solo drift relevante para operar OpenClaw con prudencia: + +- ownership y permisos; +- helper readonly; +- Telegram runtime; +- staging vs deploy; +- bind loopback vs `bind: "lan"`; +- template `compose` vs runtime vivo; +- observabilidad. + +Todo lo no confirmado aquí debe seguir etiquetado como `pendiente de verificación en host`. + +## tabla de drift conocido + +| área | diseño/documentación | plantilla o repo | estado host observado | riesgo | decisión temporal | +| --- | --- | --- | --- | --- | --- | +| ownership y permisos | mínimo privilegio y separación de roles | scripts y docs históricamente no dejaban este reparto suficientemente explícito | ownership mixto observado: `root` conserva `compose`, `broker`, `dropzone` y secretos; `devops` posee `config`, `state` y `logs` | cambiar ownership por simplificación puede ampliar superficie o romper controles | documentar el reparto actual como deliberado; no proponer `chown -R` | +| helper readonly | observabilidad controlada sin abrir `/opt/automation` | la documentación quedó en versión de 4 modos | el helper observado expone 5 modos, incluido `operational_logs_recent` | documentación incompleta induce validaciones parciales o instalación incorrecta | tratar el helper como vía preferente de observabilidad controlada y actualizar docs | +| Telegram runtime | Telegram persistente y acotado por broker/policy | el repo ya documenta runtime persistente, pero la telemetría sigue mínima | servicio operativo con degradación intermitente de polling y estado persistente mínimo | venderlo como “cerrado” ocultaría degradación real | mantener estado operativo pero degradado; no ampliar claims | +| staging vs deploy | staging y validación previa al deploy | `scripts/agents/openclaw/README.md` llamaba staging a todo el tramo | `30_first_local_deploy.sh` sí crea red, copia runtime, valida upstream y despliega con Docker | confusión documental puede empujar a ejecución equivocada | separar explícitamente staging, prechecks y deploy real | +| bind loopback vs `bind: "lan"` | gateway northbound local-only | `openclaw.json.example` mantiene `bind: "lan"` | publish observado del host en `127.0.0.1:18789` | corregir la config a ciegas podría cambiar semántica del runtime | tratarlo como drift contractual; documentar y dejar decisión pendiente | +| compose/template vs runtime vivo | el repo ofrece contrato de despliegue base | plantilla `docker-compose` no coincide exactamente con el `compose` vivo en healthcheck y detalles de arranque | runtime observado sano, pero con diferencias materiales frente a plantilla | redeploy ciego puede reintroducir drift o cambiar comportamiento | no redeployar a ciegas; revisar drift antes de tocar runtime | +| observabilidad | helper readonly y consola como baseline seguro | faltaba un documento único de drift y faltaba precheck readonly del repo | la operación depende de distinguir documentación, plantilla y estado observado | sin precheck, el repo puede volver a divergir sin señal temprana | añadir documento formal de drift y precheck readonly de coherencia | + +## ownership y permisos + +Estado observado y ya auditado: + +- `root` conserva `compose`, `broker`, `dropzone` y secretos; +- `devops` posee `config`, `state` y `logs`. + +Juicio documental: + +- este reparto debe considerarse deliberado y alineado con mínimo privilegio; +- no debe proponerse `chown -R` completo a `devops` como “simplificación”; +- si en el futuro hiciera falta más lectura operativa para `devops`, la vía preferente sigue siendo helper readonly o un mecanismo acotado equivalente, no apertura general del runtime. + +## helper readonly + +Estado observado: + +- existe helper readonly host-side; +- la interfaz observada incluye cinco modos: + - `runtime_summary` + - `broker_state_console` + - `broker_audit_recent` + - `telegram_runtime_status` + - `operational_logs_recent` + +Decisión temporal: + +- la documentación del repo debe reflejar ya esos cinco modos; +- no ampliar el helper a mutaciones; +- mantenerlo como vía preferente de observabilidad controlada. + +## Telegram runtime + +Estado observado: + +- Telegram persiste estado runtime mínimo; +- la operación existe, pero con degradación/intermitencia de polling. + +Decisión temporal: + +- documentar Telegram como operativo y persistente; +- no vender cierre funcional completo; +- dejar explícito que la telemetría sigue siendo limitada para una operación madura. + +## staging vs deploy + +Drift confirmado: + +- `10_stage_runtime.sh` y `20_validate_runtime_readiness.sh` siguen siendo de staging y precheck; +- `30_first_local_deploy.sh` ya no es staging: despliega runtime real con Docker y toca artefactos vivos del runtime. + +Decisión temporal: + +- la documentación debe separar esos tramos; +- no ejecutar `30_first_local_deploy.sh` como si fuera un paso inocuo de scaffold. + +## bind loopback vs `bind: "lan"` + +Estado documental: + +- el diseño northbound vigente vende exposición local-only; +- `templates/openclaw/openclaw.json.example` mantiene `bind: "lan"`. + +Estado host observado: + +- publish northbound del gateway en `127.0.0.1:18789`. + +Juicio técnico: + +- el control local-only observado está asegurado northbound por publish loopback en host; +- persiste un drift semántico entre ese publish y `bind: "lan"` en config; +- no debe corregirse el runtime por reflejo documental. + +## compose/template vs runtime vivo + +Estado documental: + +- el repo contiene plantillas versionadas para componer un despliegue reproducible. + +Riesgo actual: + +- si la plantilla y el runtime vivo divergen materialmente, un redeploy a ciegas puede introducir cambios no revisados. + +Decisión temporal: + +- tratar el `compose` versionado como baseline declarativa, no como espejo exacto del runtime vivo; +- exigir precheck de drift del repo antes de cualquier intervención posterior. + +## observabilidad + +Postura vigente: + +- el helper readonly es la vía preferente de observabilidad controlada; +- la consola puede degradar de lectura directa a helper sin abrir permisos generales; +- faltaba un documento formal de drift y un precheck de coherencia del repo. + +Con este documento y el script `scripts/agents/openclaw/40_runtime_drift_readonly.sh`, el repo gana una barrera documental mínima antes de cualquier redeploy o refactor operativo posterior. + +## qué no hacer todavía + +- no redeployar a ciegas mientras exista drift material repo/host; +- no corregir funcionalmente `templates/openclaw/docker-compose.yaml`; +- no corregir funcionalmente `templates/openclaw/openclaw.json.example`; +- no modificar units `systemd`, sudoers ni scripts mutantes solo para “alinear” el repo; +- no proponer `chown -R` completo a `devops`; +- no vender como validado en host nada que aquí solo quede documentado. + +## parche pendiente para obsi-claw-AI_agent + +Parche textual pendiente para aplicar manualmente en: + +- `docs/evidence/VALIDACION_HELPER_READONLY_SPRINT_1.md` + +Cambios recomendados: + +- sustituir toda referencia a “cuatro modos” por “cinco modos”; +- actualizar la línea de `Usage` para incluir `operational_logs_recent`; +- ampliar la tabla de validación con una fila específica para `operational_logs_recent`; +- ajustar el resumen ejecutivo para indicar que el helper observado ya cubre también logs operativos recientes de unidades permitidas; +- mantener explícito que sigue siendo readonly y que esto no amplía permisos mutantes ni acceso general a `/opt/automation`. diff --git a/docs/TELEGRAM_OPENCLAW_ASSISTANT_LLM_PHASE_16.md b/docs/TELEGRAM_OPENCLAW_ASSISTANT_LLM_PHASE_16.md new file mode 100644 index 0000000..97225c1 --- /dev/null +++ b/docs/TELEGRAM_OPENCLAW_ASSISTANT_LLM_PHASE_16.md @@ -0,0 +1,283 @@ +# Telegram OpenClaw Assistant LLM Phase 16 + +Fecha: 2026-04-01 +Rama objetivo: `codex/openclaw-console-readonly` +Estado: propuesta lista para implementación + +## objetivo + +Evolucionar el canal Telegram de OpenClaw desde un asistente conversacional cerrado por reglas hacia un asistente más natural, tipo GPT, sin romper el modelo de seguridad actual. + +La meta no es crear un chatbot libre ni darle acceso general al VPS. La meta es mejorar: + +- comprensión de lenguaje natural +- redacción de respuestas +- propuestas de acción + +manteniendo: + +- allowlist Telegram +- `operator_auth` +- policy viva +- broker restringido +- confirmación obligatoria para mutaciones +- auditoría completa + +## decisión de arquitectura + +La estrategia recomendada es: + +- mantener el asistente cerrado actual como base determinista +- añadir un adapter LLM seguro y opcional solo para comprensión/redacción +- no crear todavía un segundo bot separado + +En resumen: + +- A sola tiene techo rápido +- B sobre A es la mejor evolución inmediata +- C se puede evaluar más adelante, no ahora + +## principio de seguridad + +El LLM no debe ejecutar nada directamente. + +El LLM solo puede: + +- interpretar intención +- proponer una acción cerrada +- redactar una respuesta natural +- resumir estado, auditoría o logs permitidos + +El LLM no puede: + +- ejecutar shell +- construir comandos Bash +- leer el filesystem libremente +- hablar con Docker o systemd +- mutar policy por su cuenta +- saltarse confirmación + +## arquitectura propuesta + +```text +Telegram + -> Telegram Bot Adapter + -> Intent Router + -> Rule Engine + -> LLM Adapter (fallback controlado) + -> Schema Validator + -> Policy/Auth Guard + -> Confirmation Manager + -> Restricted Broker + -> Audit Logger +``` + +## flujo operativo + +1. Telegram recibe un mensaje. +2. El bot resuelve identidad por `chat_id/user_id -> operator_id`. +3. Si el mensaje es slash command o confirmación, se resuelve por reglas. +4. Si es conversación natural: + - primero intenta matcher local + - si no basta y la sesión está en `wake`, se llama al LLM +5. El LLM devuelve una salida estructurada cerrada. +6. El backend valida schema y campos permitidos. +7. El backend valida auth + policy. +8. Si es lectura, responde. +9. Si es mutación, propone acción y pide confirmación. +10. Solo tras confirmación se ejecuta por broker o mutación controlada. + +## regla clave + +El LLM nunca decide la ejecución real. + +La ejecución real siempre sigue esta cadena: + +`Telegram -> auth -> policy -> confirmation -> broker` + +## cuándo invocar LLM + +Sí: + +- preguntas naturales ambiguas o abiertas +- explicación del estado +- reformulación de auditoría +- propuesta de acción +- frases tipo: + - `quién eres` + - `qué recomiendas` + - `explícame qué está pasando` + - `resume lo importante` + +No: + +- slash commands +- confirmaciones `si/no` +- parsing crítico de parámetros ya bien cubierto por reglas +- ejecución directa de mutaciones +- cualquier mensaje que ya encaje limpiamente en el modo cerrado + +## contrato de salida del LLM + +La salida del modelo debe ser JSON estructurado y validado. No texto libre para acciones. + +Ejemplo: + +```json +{ + "intent": "enable_capability_with_ttl", + "action_id": "action.dropzone.write.v1", + "params": { + "ttl_minutes": 15 + }, + "needs_confirmation": true, + "reply": "Puedo habilitar temporalmente esa capacidad durante 15 minutos." +} +``` + +## intents permitidos + +Lectura: + +- `status` +- `capabilities` +- `audit_tail` +- `logs_read` +- `explain_status` +- `suggest_action` +- `assistant_identity` + +Mutación controlada: + +- `enable_capability` +- `disable_capability` +- `enable_capability_with_ttl` +- `reset_one_shot` + +Fallback: + +- `unsupported` + +## validaciones obligatorias + +Antes de aceptar la salida del LLM: + +- JSON válido +- schema válido +- `intent` dentro de allowlist +- `action_id` existente si aplica +- `params` permitidos y tipados +- longitud acotada +- sin campos extra peligrosos + +Antes de ejecutar: + +- operador allowlisted +- permiso del operador +- acción permitida por policy +- confirmación obligatoria si es mutación + +## fallback seguro + +Si el LLM: + +- no responde +- responde fuera de schema +- propone una acción inexistente +- propone algo no autorizado + +entonces el sistema: + +- no ejecuta nada +- audita el rechazo +- responde con ayuda cerrada o cae al modo por reglas + +## auditoría propuesta + +Eventos adicionales: + +- `llm_invoked` +- `llm_output_validated` +- `llm_output_rejected` +- `response_generated` + +Se mantienen: + +- `assistant_wake` +- `assistant_sleep` +- `intent_detected` +- `intent_rejected_unsupported` +- `intent_rejected_unauthorized` +- `confirmation_requested` +- `confirmation_accepted` +- `confirmation_rejected` +- `action_executed` +- `action_failed` + +## aislamiento del runtime + +El bot/adapter debe seguir corriendo con aislamiento fuerte: + +- usuario de sistema dedicado +- sin shell arbitraria +- sin sudo general +- acceso solo a policy, audit, runtime state y broker +- red solo hacia Telegram y proveedor LLM si se activa + +El LLM no debe recibir: + +- secretos del host +- rutas amplias del sistema +- contenido libre del filesystem +- comandos internos + +## proveedor LLM + +Se puede usar un proveedor ligero y rápido como Gemini Flash o equivalente, pero siempre como adapter opcional. + +Variables de entorno sugeridas: + +- `OPENCLAW_LLM_ENABLED=true|false` +- `OPENCLAW_LLM_PROVIDER=gemini` +- `OPENCLAW_LLM_MODEL=gemini-2.5-flash` +- `OPENCLAW_LLM_API_KEY=...` +- `OPENCLAW_LLM_TIMEOUT_SECONDS=5` + +Todas fuera del repo. + +## estrategia recomendada + +Implementar `B sobre A`: + +- conservar el modo cerrado actual +- añadir adapter LLM solo como fallback en `wake` +- mantener mutaciones con confirmación y validación local + +No hacer todavía: + +- bot nuevo separado +- tools abiertas +- shell controlada por modelo +- acceso amplio al VPS + +## plan de implementación fase 16 + +1. Añadir `llm_adapter.py` +2. Añadir schema cerrado para intents +3. Integrar invocación opcional del LLM en `wake mode` +4. Mantener fallback a reglas +5. Auditar invocación y validación +6. Añadir tests de: + - output válido + - output inválido + - fallback seguro + - mutación con confirmación + - rechazo de acción no autorizada + +## criterio de éxito + +- Telegram se siente más natural +- no existe ejecución arbitraria +- el modelo de seguridad actual se mantiene +- el proyecto no se desvía hacia un agente libre +- el operador conserva trazabilidad y control diff --git a/docs/TELEGRAM_OPENCLAW_CONVERSATIONAL_MVP.md b/docs/TELEGRAM_OPENCLAW_CONVERSATIONAL_MVP.md index ada00c8..1be60a1 100644 --- a/docs/TELEGRAM_OPENCLAW_CONVERSATIONAL_MVP.md +++ b/docs/TELEGRAM_OPENCLAW_CONVERSATIONAL_MVP.md @@ -1,5 +1,9 @@ # Telegram OpenClaw Conversational MVP +Nota de alcance: + +Este documento describe el MVP conversacional inicial basado solo en reglas. Su valor actual es histórico. El estado operativo posterior fue ampliado por las Fases 16–17 con fallback LLM controlado, `local-first` y mejoras de redacción/explicación sin cambiar el perímetro de seguridad. + ## objetivo Añadir una capa conversacional pequeña y controlada sobre el bot Telegram existente, sin convertirlo en un chatbot libre ni alterar el modelo de seguridad actual. diff --git a/docs/TELEGRAM_OPENCLAW_LLM_FALLBACK_PHASE_16_17.md b/docs/TELEGRAM_OPENCLAW_LLM_FALLBACK_PHASE_16_17.md new file mode 100644 index 0000000..d3ea31c --- /dev/null +++ b/docs/TELEGRAM_OPENCLAW_LLM_FALLBACK_PHASE_16_17.md @@ -0,0 +1,146 @@ +# Telegram OpenClaw LLM Fallback Phase 16-17 + +## objetivo + +Describir el estado operativo actual del canal Telegram de OpenClaw tras las Fases 16 y 17: + +- fallback LLM controlado sobre el asistente existente +- `local-first` intacto +- mejora de UX conversacional sin cambiar seguridad ni ejecución real + +## principios mantenidos + +- Telegram sigue siendo solo canal. +- `operator_auth`, policy viva y auditoría siguen mandando. +- El broker restringido y la ruta actual de mutaciones siguen siendo el plano real de ejecución. +- No hay shell arbitraria. +- No hay tools abiertas ni acceso libre al filesystem. +- Las mutaciones siguen bajo confirmación explícita. + +## modelo operativo actual + +Flujo conceptual: + +1. Telegram recibe mensaje. +2. Se resuelve `chat_id/user_id -> operator_id`. +3. Slash commands y confirmaciones siguen por reglas. +4. En conversación: + - primero se intenta matcher local + - solo si la sesión está en `wake` y el matcher no resuelve, entra el fallback LLM +5. La salida LLM se valida localmente contra un contrato cerrado. +6. Lecturas, propuestas y confirmaciones siguen pasando por auth/policy/ruta actual/auditoría. + +## local-first y wake mode + +- Fuera de `wake`, no se usa LLM. +- En `wake`, las frases ya cubiertas por reglas siguen resolviéndose por reglas. +- El LLM solo entra como fallback sobre frases abiertas o ambiguas no resueltas localmente. + +Ejemplos que siguen por reglas: + +- `quien eres` +- `como estamos` +- `que puedes hacer` +- confirmaciones `si/no` +- slash commands como `/status` y `/capabilities` + +## contrato cerrado del LLM + +El LLM no ejecuta nada. Solo devuelve una salida estructurada cerrada para: + +- `status` +- `capabilities` +- `audit_tail` +- `logs_read` +- `explain_status` +- `suggest_action` +- `enable_capability` +- `disable_capability` +- `enable_capability_with_ttl` +- `reset_one_shot` +- `unsupported` + +La salida se valida localmente: + +- JSON válido +- sin claves extra +- `intent` dentro de allowlist +- `action_id` coherente con el intent +- `params` tipados y restringidos +- `needs_confirmation` coherente con lectura o mutación + +Si falla esa validación, el sistema no ejecuta nada y cae al modo seguro. + +## qué sigue yendo por reglas + +- slash commands +- `wake/sleep` +- confirmaciones `si/no` +- intents ya cubiertos por matcher local +- ejecución real de lecturas y mutaciones +- confirmación obligatoria para mutaciones + +## mejoras introducidas por fase 17 + +### suggest_action + +Se afinó el comportamiento para que la recomendación sea más prudente: + +- prioriza auditoría, capacidades y logs permitidos +- evita proponer de primeras acciones sensibles de control +- si llegara a sugerir un cambio, lo presenta como propuesta prudente, no como recomendación fuerte + +### explain_status + +Se mejoró la explicación conceptual de estados: + +- `enabled`: capacidad disponible ahora mismo +- `disabled`: capacidad cerrada explícitamente +- `expired`: capacidad cuyo TTL ya venció +- `consumed` / `one-shot`: capacidad de un solo uso ya consumida + +La respuesta ya no se limita a repetir el bloque de estado actual. + +## auditoría relevante + +Eventos añadidos o usados en esta capa: + +- `llm_invoked` +- `llm_output_validated` +- `llm_output_rejected` +- `intent_detected` +- `intent_rejected_unsupported` +- `confirmation_requested` +- `confirmation_accepted` +- `confirmation_rejected` +- `response_generated` + +## límites conocidos + +- no es un chatbot libre +- no hay shell +- no hay tools abiertas +- no hay memoria larga +- mutaciones requieren confirmación +- la calidad del fallback depende del prompt y del modelo + +## estado real actual del runtime + +Estado actual confirmado: + +- Gemini activo en runtime real como fallback controlado +- `local-first` intacto +- sin alterar el perímetro `auth/policy/broker` +- slash commands y confirmaciones siguen por reglas +- mutaciones siguen bajo confirmación estricta + +## validación funcional real en Telegram + +Validación mínima ya comprobada en operación: + +- `/status` OK +- `/capabilities` OK +- frases conocidas por reglas en `wake` OK +- frase abierta en `wake` usando fallback LLM OK +- mutación sin confirmación no se ejecuta +- auditoría de `llm_invoked` y `llm_output_validated` / `llm_output_rejected` OK diff --git a/docs/TELEGRAM_OPENCLAW_RUNTIME_FINAL.md b/docs/TELEGRAM_OPENCLAW_RUNTIME_FINAL.md index bd525fa..7c402a0 100644 --- a/docs/TELEGRAM_OPENCLAW_RUNTIME_FINAL.md +++ b/docs/TELEGRAM_OPENCLAW_RUNTIME_FINAL.md @@ -11,6 +11,8 @@ Queda instalado como servicio `systemd`: - unit: `/etc/systemd/system/openclaw-telegram-bot.service` - env runtime: `/etc/davlos/secrets/openclaw/telegram-bot.env` - policy viva: `/opt/automation/agents/openclaw/broker/restricted_operator_policy.json` +- helper readonly host-side: `/usr/local/sbin/davlos-openclaw-readonly` +- sudoers mínimo del helper: `/etc/sudoers.d/davlos-openclaw-readonly` El modelo de seguridad no cambia: @@ -97,6 +99,21 @@ cat /opt/automation/agents/openclaw/broker/state/telegram_runtime_status.json tail -n 20 /opt/automation/agents/openclaw/broker/audit/restricted_operator.jsonl ``` +### observabilidad recomendada desde consola + +```bash +bash /opt/control-plane/scripts/console/davlos-vpn-console.sh overview +bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-telegram +bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-capabilities-audit +bash /opt/control-plane/scripts/console/davlos-vpn-console.sh openclaw-diagnostics +``` + +Si la sesión `devops` no puede leer directamente `/opt/automation/agents/openclaw/broker/state`, la consola usa el helper readonly para: + +- `telegram_runtime_status.json` +- auditoría reciente del broker +- estado efectivo de capacidades + ## validación final esperada Comandos ya validados en Telegram: @@ -142,3 +159,4 @@ Rollback lógico adicional, si hiciera falta: - el secreto vive solo en runtime host-side - la separación de permisos depende de `operator_auth` y de la policy viva - no se han añadido nuevas acciones ni nuevos canales +- la observabilidad detallada sin root depende del helper readonly o de permisos equivalentes de lectura diff --git a/docs/TELEGRAM_OPENCLAW_WAKE_MODE_MVP.md b/docs/TELEGRAM_OPENCLAW_WAKE_MODE_MVP.md new file mode 100644 index 0000000..979d36f --- /dev/null +++ b/docs/TELEGRAM_OPENCLAW_WAKE_MODE_MVP.md @@ -0,0 +1,124 @@ +# Telegram OpenClaw Wake Mode MVP + +## objetivo + +Añadir un modo `wake/sleep` al canal Telegram de OpenClaw para permitir una conversación más natural sin convertir el bot en un agente libre. + +## principios + +- Telegram sigue siendo solo canal. +- La identidad real sigue resolviéndose por allowlist Telegram -> `operator_id`. +- La autorización real sigue en `operator_auth`. +- La ejecución real sigue en broker restringido o en mutaciones controladas de policy. +- No se introduce shell arbitraria. + +## modelo de sesión + +Cada sesión se indexa por `chat_id:user_id`. + +Estado en memoria: + +- `assistant_wake` +- `assistant_sleep` +- `last_activity_at` +- `operator_id` +- confirmación pendiente por chat/user + +Timeout por inactividad: + +- default `300s` +- configurable por env `OPENCLAW_TELEGRAM_ASSISTANT_IDLE_TIMEOUT_SECONDS` +- al expirar, se cierra la sesión y se limpia cualquier confirmación pendiente + +## comandos + +- `/wake`: activa modo asistente +- `/sleep`: sale del modo asistente +- slash commands actuales se mantienen: + - `/help` + - `/status` + - `/capabilities` + - `/audit_tail` + - `/execute [k=v ...]` + +Equivalentes naturales mínimos: + +- `despierta` +- `despierta openclaw` +- `duerme` +- `duermete` + +## conversación soportada + +Fuera de `wake`: + +- sigue activo el modo conversacional cerrado de la fase anterior +- frases estrictas y seguras + +En `wake`: + +- estado general +- capacidades activas +- auditoría reciente +- logs permitidos +- explicación del estado +- propuesta de acción + +Ejemplos: + +- `como estamos` +- `que puedes hacer` +- `que ha pasado` +- `logs openclaw 20` +- `explica el estado` +- `que propones` + +## mutaciones + +Las mutaciones siguen flujo controlado: + +1. detectar intención +2. comprobar autorización de mutación frente a policy +3. proponer acción concreta +4. pedir confirmación +5. ejecutar solo tras confirmación explícita + +Confirmaciones aceptadas: + +- `si` +- `sí` +- `confirmar` +- `confirmo` +- `ok` +- `dale` + +Confirmaciones negativas: + +- `no` +- `cancelar` +- `cancela` +- `rechazar` + +## auditoría + +Eventos relevantes: + +- `assistant_wake` +- `assistant_sleep` +- `intent_detected` +- `intent_rejected_unsupported` +- `intent_rejected_unauthorized` +- `response_generated` +- `confirmation_requested` +- `confirmation_accepted` +- `confirmation_rejected` +- `action_executed` +- `action_failed` + +## límites + +- la sesión vive en memoria del proceso +- no hay memoria larga +- el parser sigue siendo cerrado por reglas +- no existe ejecución libre ni prompts abiertos contra shell +- las propuestas son textuales; la ejecución real sigue separada diff --git a/docs/reports/OPENCLAW_PHASE_15_WAKE_MODE_2026-04-01.md b/docs/reports/OPENCLAW_PHASE_15_WAKE_MODE_2026-04-01.md new file mode 100644 index 0000000..aff698d --- /dev/null +++ b/docs/reports/OPENCLAW_PHASE_15_WAKE_MODE_2026-04-01.md @@ -0,0 +1,71 @@ +# OPENCLAW Phase 15 Wake Mode + +Fecha: 2026-04-01 +Rama: `codex/openclaw-console-readonly` + +## objetivo + +Evolucionar el bot Telegram desde un modo conversacional básico por reglas hacia un modo asistente `wake/sleep`, manteniendo intacto el modelo de seguridad actual. + +## cambios aplicados + +- sesión efímera por `chat_id:user_id` +- comandos `/wake` y `/sleep` +- timeout por inactividad +- integración con el modo conversacional previo +- respuestas más naturales en modo despierto +- confirmación mutante mantenida +- rechazo temprano de mutaciones fuera de permisos +- auditoría adicional de sesión y respuestas + +## alcance funcional + +En modo despierto: + +- consulta de estado general +- consulta de capacidades activas +- consulta de auditoría reciente +- consulta de logs permitidos +- explicación resumida del estado +- propuesta textual de acción segura + +Mutaciones soportadas: + +- habilitar/deshabilitar capacidad +- habilitar con TTL +- resetear one-shot + +## seguridad + +- no se añadió shell arbitraria +- no se añadió ejecución libre +- la allowlist Telegram sigue resolviendo `operator_id` +- la autorización sigue pasando por `operator_auth` +- la ejecución real sigue pasando por broker o mutaciones controladas de policy + +## tests + +Cobertura añadida: + +- `wake` +- `sleep` +- timeout +- consulta natural en modo despierto +- mutación con confirmación en modo despierto +- rechazo de mutación fuera de permisos + +Comando de validación: + +```bash +python3 -m unittest tests.restricted_operator.test_broker +``` + +## riesgos residuales + +- la sesión y la confirmación pendiente siguen en memoria del proceso +- el lenguaje soportado sigue siendo deliberadamente estrecho +- no hay persistencia cross-restart de contexto conversacional + +## salida + +Esta fase deja Telegram en un punto útil de “asistente despierto” sin romper el perímetro actual de seguridad. diff --git a/scripts/agents/openclaw/40_runtime_drift_readonly.sh b/scripts/agents/openclaw/40_runtime_drift_readonly.sh new file mode 100755 index 0000000..02ea066 --- /dev/null +++ b/scripts/agents/openclaw/40_runtime_drift_readonly.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Precheck readonly de coherencia del repo para OpenClaw. +# Este script NO valida el host, NO inspecciona runtime vivo y NO muta nada. +# Su único objetivo es detectar drift documental/material dentro del propio repo +# antes de plantear un redeploy o una intervención posterior. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +README_ROOT="${REPO_ROOT}/README.md" +DOCS_AGENTS="${REPO_ROOT}/docs/AGENTS.md" +SCRIPT_README="${SCRIPT_DIR}/README.md" +HELPER_DOC="${REPO_ROOT}/docs/OPENCLAW_READONLY_HELPER_INSTALL.md" +DRIFT_DOC="${REPO_ROOT}/docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md" +DEPLOY_SCRIPT="${SCRIPT_DIR}/30_first_local_deploy.sh" + +failures=0 + +print_check() { + local status="$1" + local message="$2" + printf '%s %s\n' "${status}" "${message}" +} + +fail() { + local message="$1" + print_check "FAIL" "${message}" + failures=$((failures + 1)) +} + +pass() { + local message="$1" + print_check "OK " "${message}" +} + +check_file() { + local path="$1" + if [[ -f "${path}" ]]; then + pass "archivo presente: ${path}" + else + fail "archivo ausente: ${path}" + fi +} + +echo "== openclaw repo drift readonly precheck ==" +echo "scope=repo_only" +echo "repo_root=${REPO_ROOT}" + +check_file "${README_ROOT}" +check_file "${DOCS_AGENTS}" +check_file "${SCRIPT_README}" +check_file "${HELPER_DOC}" +check_file "${DRIFT_DOC}" +check_file "${DEPLOY_SCRIPT}" + +if rg -q '30_first_local_deploy\.sh' "${SCRIPT_README}" && \ + rg -q 'deploy real|despliega `openclaw-gateway`|impacto potencial sobre runtime vivo' "${SCRIPT_README}"; then + pass "scripts/agents/openclaw/README.md distingue staging/prechecks de deploy real" +else + fail "scripts/agents/openclaw/README.md no deja suficientemente claro que 30_first_local_deploy.sh es deploy real" +fi + +if rg -q 'broker restringido operativo|Telegram persistente operativo|helper readonly' "${README_ROOT}" && \ + rg -q 'broker restringido operativo|Telegram persistente operativo|helper readonly' "${DOCS_AGENTS}"; then + pass "README.md y docs/AGENTS.md alinean broker, Telegram y helper como parte del runtime observado" +else + fail "docs/AGENTS.md y README.md principal siguen desalineados sobre broker, Telegram o helper readonly" +fi + +if rg -q 'operational_logs_recent' "${HELPER_DOC}" && \ + rg -q 'operational_logs_recent' "${REPO_ROOT}/templates/openclaw/davlos-openclaw-readonly.sh"; then + pass "la documentación del helper refleja el quinto modo operational_logs_recent" +else + fail "la documentación del helper no refleja operational_logs_recent como quinto modo" +fi + +if rg -q 'bind: "lan"|loopback-only|drift contractual' "${DRIFT_DOC}"; then + pass "el documento de drift recoge el gap loopback-only vs bind: lan" +else + fail "el documento de drift no recoge claramente el gap loopback-only vs bind: lan" +fi + +if rg -q 'ownership mixto|root.*compose.*broker.*dropzone.*secretos|devops.*config.*state.*logs' "${README_ROOT}" && \ + rg -q 'ownership observado del runtime' "${DOCS_AGENTS}"; then + pass "la documentación crítica ya deja explícito el ownership mixto observado" +else + fail "la documentación crítica no deja suficientemente claro el ownership mixto observado" +fi + +echo "summary_failures=${failures}" + +if (( failures > 0 )); then + echo "resultado=DRIFT_MATERIAL_DETECTADO" + exit 1 +fi + +echo "resultado=REPO_COHERENCIA_MINIMA_OK" diff --git a/scripts/agents/openclaw/README.md b/scripts/agents/openclaw/README.md index ef3807a..9bca67f 100644 --- a/scripts/agents/openclaw/README.md +++ b/scripts/agents/openclaw/README.md @@ -1,10 +1,35 @@ -# OpenClaw runtime staging scripts +# OpenClaw runtime staging, prechecks y deploy ## propósito -Estos scripts preparan y validan el scaffold mínimo del runtime de OpenClaw sin desplegar contenedores ni tocar servicios existentes. +Este directorio mezcla tres tipos de scripts distintos: -## qué preparan +- staging del runtime; +- validación previa; +- deploy real. + +No deben tratarse como equivalentes. + +## tramos documentados + +### staging y prechecks + +`10_stage_runtime.sh` y `20_validate_runtime_readiness.sh` preparan y validan scaffold mínimo del runtime sin desplegar contenedores ni tocar servicios existentes. + +### deploy real + +`30_first_local_deploy.sh` ya no es un script de staging: + +- usa Docker; +- puede crear `agents_net`; +- copia artefactos al runtime efectivo; +- genera o actualiza `.env`; +- despliega `openclaw-gateway`; +- ejecuta validaciones post-deploy. + +Por tanto, no debe ejecutarse como si fuera un paso inocuo de preparación. + +## qué preparan los tramos de staging - layout base bajo `/opt/automation/agents/openclaw` - ruta vacía de secretos en `/etc/davlos/secrets/openclaw` @@ -13,7 +38,7 @@ Estos scripts preparan y validan el scaffold mínimo del runtime de OpenClaw sin - `templates/openclaw/openclaw.env.example` - `templates/openclaw/openclaw.json.example` -## qué NO hacen +## qué NO hacen `10_stage_runtime.sh` y `20_validate_runtime_readiness.sh` - no crean `agents_net` - no arrancan contenedores @@ -48,13 +73,35 @@ Se elige la opción A: no crear `config/openclaw.json` en este tramo y dejarlo m - distingue entre `config/openclaw.json.example` y `config/openclaw.json` - no depende de Docker -## cómo ejecutarlos +### `30_first_local_deploy.sh` + +- requiere `root` o `sudo` +- usa Docker, `curl` y `openssl` +- crea o valida `agents_net` +- escribe sobre runtime efectivo bajo `/opt/automation/agents/openclaw` +- despliega `openclaw-gateway` +- debe considerarse deploy real con impacto potencial sobre runtime vivo + +## cómo ejecutar staging y prechecks ```bash sudo bash /opt/control-plane/scripts/agents/openclaw/10_stage_runtime.sh sudo bash /opt/control-plane/scripts/agents/openclaw/20_validate_runtime_readiness.sh ``` +## antes de cualquier deploy real + +Antes de plantear `30_first_local_deploy.sh`: + +- revisar `docs/OPENCLAW_RUNTIME_DRIFT_2026-04-08.md`; +- ejecutar `scripts/agents/openclaw/40_runtime_drift_readonly.sh`; +- confirmar que no existe drift material repo/runtime que vuelva inseguro un redeploy; +- recordar que el runtime observado usa ownership mixto deliberado: + - `root` conserva `compose`, `broker`, `dropzone` y secretos; + - `devops` posee `config`, `state` y `logs`. + +No usar este directorio para justificar redeploys ciegos. + ## estados del validador - `NOT_STAGED` diff --git a/scripts/agents/openclaw/restricted_operator/assistant_responses.py b/scripts/agents/openclaw/restricted_operator/assistant_responses.py new file mode 100644 index 0000000..4ab9503 --- /dev/null +++ b/scripts/agents/openclaw/restricted_operator/assistant_responses.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from models import EffectiveActionState + + +def render_mutation_result(*, ok: bool, summary: str, action_id: str) -> str: + if ok: + return f"Acción aplicada.\n{summary}\naction_id={action_id}" + return f"No se pudo aplicar la acción.\n{summary}\naction_id={action_id}" + + +def render_conversation_help() -> str: + return ( + "No entendí la intención o no está soportada.\n" + "Prueba una de estas frases:\n" + "- estado general\n" + "- capacidades activas\n" + "- auditoría reciente\n" + "- logs openclaw 20\n" + "- habilita action.dropzone.write.v1\n" + "- deshabilita action.dropzone.write.v1\n" + "- habilita action.dropzone.write.v1 por 15 minutos\n" + "- resetea one-shot action.webhook.trigger.v1\n" + "Si quieres hablar de forma más natural, usa /wake." + ) + + +def render_help(operator_id: str) -> str: + return ( + f"operator={operator_id}\n" + "/wake\n" + "/sleep\n" + "/status\n" + "/capabilities\n" + "/audit_tail\n" + "/execute [k=v ...]\n" + "Conversacional: estado general | capacidades activas | auditoría reciente | logs openclaw 20" + ) + + +def render_assistant_status(*, operator_id: str, total_actions: int, summary: dict[str, int]) -> str: + return ( + "Estado general de OpenClaw:\n" + f"- operador activo: {operator_id}\n" + f"- capacidades totales: {total_actions}\n" + f"- activas: {summary.get('enabled', 0)}\n" + f"- deshabilitadas: {summary.get('disabled', 0)}\n" + f"- expiradas: {summary.get('expired', 0)}\n" + f"- one-shot consumidas: {summary.get('consumed', 0)}" + ) + + +def render_assistant_capabilities(*, rows: list[str]) -> str: + return "Capacidades activas y visibles para este operador:\n" + ( + "\n".join(rows) if rows else "- no hay acciones configuradas" + ) + + +def render_assistant_audit_tail(body: str) -> str: + return "Últimos eventos de auditoría que puedo mostrarte:\n" + body + + +def render_assistant_explanation( + *, + operator_id: str, + disabled: list[str], + expired: list[str], + consumed: list[str], +) -> str: + lines = [ + "Lectura prudente de los estados de capacidad:", + "- enabled: la capacidad está disponible ahora mismo dentro de policy y permisos.", + "- disabled: la capacidad está cerrada de forma explícita hasta que alguien la vuelva a habilitar.", + "- expired: estuvo habilitada con tiempo limitado y ese plazo ya terminó.", + "- consumed/one-shot: era de un solo uso y ya se gastó; no vuelve a correr hasta reset explícito.", + ] + if not disabled and not consumed and not expired: + lines.append("- En este momento no veo señales de cierre o agotamiento en policy.") + if disabled: + lines.append(f"- Ahora mismo veo deshabilitadas: {', '.join(disabled[:3])}.") + if expired: + lines.append(f"- También veo expiradas: {', '.join(expired[:3])}.") + if consumed: + lines.append(f"- Y veo one-shot consumidas: {', '.join(consumed[:3])}.") + lines.append(f"- Operador en sesión: {operator_id}.") + lines.append("- Si quieres, puedo seguir con una lectura de auditoría, capacidades o logs permitidos.") + return "\n".join(lines) + + +def render_assistant_suggestion(*, operator_id: str, states: list[EffectiveActionState]) -> str: + disabled = [state.action_id for state in states if state.status == "disabled"] + expired = [state.action_id for state in states if state.status == "expired"] + consumed = [state.action_id for state in states if state.status == "consumed"] + if disabled or expired or consumed: + lines = [ + "Propuesta prudente:", + "- primero revisar auditoría reciente para entender por qué cambió el estado.", + "- después revisar capacidades activas/expiradas para confirmar el impacto real.", + "- si hace falta más contexto, leer logs permitidos antes de tocar nada sensible.", + ] + if expired: + lines.append(f"- Veo {len(expired)} capacidades expiradas; eso sugiere revisar si el TTL venció como estaba previsto.") + if disabled: + lines.append(f"- Veo {len(disabled)} capacidades deshabilitadas; lo prudente es confirmar si siguen debiendo estar cerradas.") + if consumed: + lines.append(f"- Veo {len(consumed)} one-shot consumidas; conviene verificar si ese consumo era esperado.") + lines.append("- Solo si el contexto lo justifica tendría sentido plantear un cambio temporal o un reset explícito.") + return "\n".join(lines) + return ( + f"No veo una mutación urgente para {operator_id}. " + "Lo prudente es seguir con auditoría, capacidades o logs permitidos antes de cambiar nada." + ) + + +def render_assistant_identity(*, operator_id: str) -> str: + return ( + "Soy el asistente controlado de OpenClaw para DAVLOS Control-Plane.\n" + f"Opero dentro de los permisos del operador {operator_id}.\n" + "Puedo resumir estado, capacidades, auditoría, logs permitidos y proponerte acciones seguras.\n" + "No ejecuto shell libre ni salgo del perímetro broker/policy." + ) + + +def render_assistant_fallback() -> str: + return ( + "No puedo interpretar esa frase de forma segura.\n" + "Puedo ayudarte con:\n" + "- estado general\n" + "- capacidades activas\n" + "- auditoría reciente\n" + "- logs openclaw 20\n" + "- explica el estado\n" + "- qué propones\n" + "- habilita/deshabilita una capacidad concreta\n" + "Para salir del modo asistente usa /sleep." + ) diff --git a/scripts/agents/openclaw/restricted_operator/assistant_session.py b/scripts/agents/openclaw/restricted_operator/assistant_session.py new file mode 100644 index 0000000..75dd2b9 --- /dev/null +++ b/scripts/agents/openclaw/restricted_operator/assistant_session.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Callable + + +@dataclass +class AssistantSession: + operator_id: str + awakened_at: float + last_activity_at: float + + +class AssistantSessionStore: + def __init__(self) -> None: + self.sessions: dict[str, AssistantSession] = {} + + @staticmethod + def session_key(*, chat_id: str, user_id: str) -> str: + return f"{chat_id}:{user_id}" + + @staticmethod + def now_monotonic() -> float: + return time.monotonic() + + def wake(self, *, chat_id: str, user_id: str, operator_id: str) -> AssistantSession: + key = self.session_key(chat_id=chat_id, user_id=user_id) + now = self.now_monotonic() + session = AssistantSession( + operator_id=operator_id, + awakened_at=now, + last_activity_at=now, + ) + self.sessions[key] = session + return session + + def sleep(self, *, chat_id: str, user_id: str) -> AssistantSession | None: + key = self.session_key(chat_id=chat_id, user_id=user_id) + return self.sessions.pop(key, None) + + def get_active( + self, + *, + chat_id: str, + user_id: str, + operator_id: str, + idle_timeout_seconds: int, + on_invalidated: Callable[[str], None] | None = None, + ) -> AssistantSession | None: + key = self.session_key(chat_id=chat_id, user_id=user_id) + session = self.sessions.get(key) + if session is None: + return None + if session.operator_id != operator_id: + self.sessions.pop(key, None) + if on_invalidated is not None: + on_invalidated("operator_mismatch") + return None + now = self.now_monotonic() + if now - session.last_activity_at > idle_timeout_seconds: + self.sessions.pop(key, None) + if on_invalidated is not None: + on_invalidated("timeout") + return None + session.last_activity_at = now + return session + + def has_active(self, *, chat_id: str, user_id: str, operator_id: str) -> bool: + key = self.session_key(chat_id=chat_id, user_id=user_id) + session = self.sessions.get(key) + return session is not None and session.operator_id == operator_id diff --git a/scripts/agents/openclaw/restricted_operator/intent_router.py b/scripts/agents/openclaw/restricted_operator/intent_router.py new file mode 100644 index 0000000..59451c0 --- /dev/null +++ b/scripts/agents/openclaw/restricted_operator/intent_router.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from intent_schema import IntentSchemaError, structured_intent_to_internal, validate_structured_intent +from llm_adapter import LLMAdapterError + + +@dataclass(frozen=True) +class RouteDecision: + intent: dict[str, Any] | None + source: str + llm_invoked: bool = False + llm_validated: bool = False + llm_rejected_reason: str | None = None + + +class IntentRouter: + def __init__( + self, + *, + local_matcher: Callable[[str, bool], dict[str, Any] | None], + llm_adapter: Any | None = None, + ): + self.local_matcher = local_matcher + self.llm_adapter = llm_adapter + + def route(self, *, text: str, assistant_awake: bool) -> RouteDecision: + local_intent = self.local_matcher(text, assistant_awake=assistant_awake) + if local_intent is not None: + return RouteDecision(intent=local_intent, source="local") + if not assistant_awake or self.llm_adapter is None or not self.llm_adapter.is_enabled(): + return RouteDecision(intent=None, source="none") + try: + raw_output = self.llm_adapter.interpret(text=text) + structured = validate_structured_intent(raw_output) + return RouteDecision( + intent=structured_intent_to_internal(structured), + source="llm", + llm_invoked=True, + llm_validated=True, + ) + except (IntentSchemaError, LLMAdapterError, ValueError, TypeError) as exc: + return RouteDecision( + intent=None, + source="llm_rejected", + llm_invoked=True, + llm_rejected_reason=str(exc), + ) diff --git a/scripts/agents/openclaw/restricted_operator/intent_schema.py b/scripts/agents/openclaw/restricted_operator/intent_schema.py new file mode 100644 index 0000000..02693a7 --- /dev/null +++ b/scripts/agents/openclaw/restricted_operator/intent_schema.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +class IntentSchemaError(ValueError): + pass + + +ALLOWED_INTENTS = { + "status", + "capabilities", + "audit_tail", + "logs_read", + "explain_status", + "suggest_action", + "enable_capability", + "disable_capability", + "enable_capability_with_ttl", + "reset_one_shot", + "unsupported", +} + +ALLOWED_REPLY_STYLES = {"brief"} +LLM_OUTPUT_KEYS = {"intent", "action_id", "params", "needs_confirmation", "reply_style"} + + +@dataclass(frozen=True) +class StructuredIntent: + intent: str + action_id: str + params: dict[str, Any] + needs_confirmation: bool + reply_style: str + + +def _require_keys(payload: dict[str, Any]) -> None: + payload_keys = set(payload.keys()) + if payload_keys != LLM_OUTPUT_KEYS: + extras = sorted(payload_keys - LLM_OUTPUT_KEYS) + missing = sorted(LLM_OUTPUT_KEYS - payload_keys) + details = [] + if missing: + details.append(f"missing={','.join(missing)}") + if extras: + details.append(f"extra={','.join(extras)}") + raise IntentSchemaError("invalid llm output keys" + (f" ({'; '.join(details)})" if details else "")) + + +def validate_structured_intent(payload: Any) -> StructuredIntent: + if not isinstance(payload, dict): + raise IntentSchemaError("llm output must be a JSON object") + _require_keys(payload) + intent = payload["intent"] + action_id = payload["action_id"] + params = payload["params"] + needs_confirmation = payload["needs_confirmation"] + reply_style = payload["reply_style"] + + if not isinstance(intent, str) or intent not in ALLOWED_INTENTS: + raise IntentSchemaError("intent not allowed") + if not isinstance(action_id, str) or not action_id: + raise IntentSchemaError("action_id must be a non-empty string") + if not isinstance(params, dict): + raise IntentSchemaError("params must be an object") + if not isinstance(needs_confirmation, bool): + raise IntentSchemaError("needs_confirmation must be boolean") + if not isinstance(reply_style, str) or reply_style not in ALLOWED_REPLY_STYLES: + raise IntentSchemaError("reply_style not allowed") + + if intent in {"status", "capabilities", "audit_tail", "explain_status", "suggest_action", "unsupported"}: + if action_id != "telegram.command": + raise IntentSchemaError("action_id not allowed for read-only assistant intent") + if params: + raise IntentSchemaError("params must be empty for read-only assistant intent") + if needs_confirmation: + raise IntentSchemaError("read-only assistant intent cannot require confirmation") + elif intent == "logs_read": + if action_id != "action.logs.read.v1": + raise IntentSchemaError("action_id not allowed for logs_read") + _validate_logs_params(params) + if needs_confirmation: + raise IntentSchemaError("logs_read cannot require confirmation") + elif intent in {"enable_capability", "disable_capability", "reset_one_shot"}: + if not action_id.startswith("action.") or not action_id.endswith(".v1"): + raise IntentSchemaError("action_id not allowed for mutation intent") + if params: + raise IntentSchemaError("params must be empty for this mutation intent") + if not needs_confirmation: + raise IntentSchemaError("mutation intent requires confirmation") + elif intent == "enable_capability_with_ttl": + if not action_id.startswith("action.") or not action_id.endswith(".v1"): + raise IntentSchemaError("action_id not allowed for ttl mutation intent") + _validate_ttl_params(params) + if not needs_confirmation: + raise IntentSchemaError("mutation intent requires confirmation") + + return StructuredIntent( + intent=intent, + action_id=action_id, + params=dict(params), + needs_confirmation=needs_confirmation, + reply_style=reply_style, + ) + + +def _validate_logs_params(params: dict[str, Any]) -> None: + keys = set(params.keys()) + if keys - {"stream_id", "tail_lines"}: + raise IntentSchemaError("unexpected logs_read params") + stream_id = params.get("stream_id") + if not isinstance(stream_id, str) or not stream_id: + raise IntentSchemaError("logs_read.stream_id is required") + if "tail_lines" in params: + tail_lines = params["tail_lines"] + if not isinstance(tail_lines, int) or tail_lines <= 0: + raise IntentSchemaError("logs_read.tail_lines must be a positive integer") + + +def _validate_ttl_params(params: dict[str, Any]) -> None: + if set(params.keys()) != {"ttl_minutes"}: + raise IntentSchemaError("enable_capability_with_ttl requires only ttl_minutes") + ttl_minutes = params["ttl_minutes"] + if not isinstance(ttl_minutes, int) or ttl_minutes <= 0: + raise IntentSchemaError("ttl_minutes must be a positive integer") + + +def structured_intent_to_internal(intent: StructuredIntent) -> dict[str, Any]: + if intent.intent == "logs_read": + return { + "intent": "logs_read", + "action_id": "action.logs.read.v1", + "params": dict(intent.params), + } + if intent.intent == "enable_capability": + return { + "intent": "enable_capability", + "action_id": intent.action_id, + "mutation": "set_enabled", + "params": {"enabled": True}, + "reason": "telegram_llm_enable", + "summary": f"Habilitar {intent.action_id}", + } + if intent.intent == "disable_capability": + return { + "intent": "disable_capability", + "action_id": intent.action_id, + "mutation": "set_enabled", + "params": {"enabled": False}, + "reason": "telegram_llm_disable", + "summary": f"Deshabilitar {intent.action_id}", + } + if intent.intent == "enable_capability_with_ttl": + ttl_minutes = int(intent.params["ttl_minutes"]) + return { + "intent": "enable_capability_with_ttl", + "action_id": intent.action_id, + "mutation": "enable_with_ttl", + "params": {"ttl_minutes": ttl_minutes}, + "reason": "telegram_llm_enable_ttl", + "summary": f"Habilitar {intent.action_id} durante {ttl_minutes} minutos", + } + if intent.intent == "reset_one_shot": + return { + "intent": "reset_one_shot", + "action_id": intent.action_id, + "mutation": "reset_one_shot", + "params": {}, + "reason": "telegram_llm_reset_one_shot", + "summary": f"Resetear one-shot de {intent.action_id}", + } + return { + "intent": intent.intent, + "action_id": intent.action_id, + "params": {}, + } diff --git a/scripts/agents/openclaw/restricted_operator/llm_adapter.py b/scripts/agents/openclaw/restricted_operator/llm_adapter.py new file mode 100644 index 0000000..a5507f6 --- /dev/null +++ b/scripts/agents/openclaw/restricted_operator/llm_adapter.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any + + +class LLMAdapterError(RuntimeError): + pass + + +@dataclass(frozen=True) +class LLMSettings: + enabled: bool + provider: str + model: str + api_key: str + timeout_seconds: int + + +class LLMAdapter: + def __init__(self, settings: LLMSettings | None = None): + self.settings = settings or self.from_env() + + @staticmethod + def from_env() -> LLMSettings: + enabled = os.environ.get("OPENCLAW_LLM_ENABLED", "false").strip().lower() == "true" + timeout_seconds = 8 + if enabled: + timeout_raw = os.environ.get("OPENCLAW_LLM_TIMEOUT_SECONDS", "8").strip() or "8" + try: + timeout_seconds = max(1, int(timeout_raw)) + except ValueError as exc: + raise LLMAdapterError("OPENCLAW_LLM_TIMEOUT_SECONDS must be an integer") from exc + return LLMSettings( + enabled=enabled, + provider=os.environ.get("OPENCLAW_LLM_PROVIDER", "gemini").strip().lower() or "gemini", + model=os.environ.get("OPENCLAW_LLM_MODEL", "gemini-2.5-flash").strip() or "gemini-2.5-flash", + api_key=os.environ.get("OPENCLAW_LLM_API_KEY", "").strip(), + timeout_seconds=timeout_seconds, + ) + + def is_enabled(self) -> bool: + return self.settings.enabled + + def interpret(self, *, text: str) -> dict[str, Any]: + if not self.settings.enabled: + raise LLMAdapterError("llm adapter is disabled") + if self.settings.provider != "gemini": + raise LLMAdapterError(f"unsupported llm provider: {self.settings.provider}") + if not self.settings.api_key: + raise LLMAdapterError("OPENCLAW_LLM_API_KEY is required when OPENCLAW_LLM_ENABLED=true") + return self._call_gemini(text=text) + + def _call_gemini(self, *, text: str) -> dict[str, Any]: + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{urllib.parse.quote(self.settings.model, safe='')}:generateContent" + f"?key={urllib.parse.quote(self.settings.api_key, safe='')}" + ) + prompt = ( + "Eres un clasificador cerrado para DAVLOS OpenClaw Telegram. " + "Devuelve solo JSON válido sin markdown ni texto adicional. " + "Claves exactas: intent, action_id, params, needs_confirmation, reply_style. " + "No inventes claves extra. " + "Intents permitidos: " + "status, capabilities, audit_tail, logs_read, explain_status, suggest_action, " + "enable_capability, disable_capability, enable_capability_with_ttl, reset_one_shot, unsupported. " + "Para status/capabilities/audit_tail/explain_status/suggest_action/unsupported usa action_id=telegram.command y params={}. " + "Para logs_read usa action_id=action.logs.read.v1 y params con stream_id y opcionalmente tail_lines. " + "Para mutaciones usa el action_id concreto y params vacíos salvo enable_capability_with_ttl, que requiere ttl_minutes entero. " + "Prioriza lecturas y observabilidad antes que mutaciones. " + "Si el usuario pide recomendación prudente, mejora operativa o algo como 'sin tocar nada sensible', devuelve suggest_action, no una mutación. " + "En suggest_action prioriza auditoría, capacidades, logs permitidos y explicación del estado. " + "No sugieras de primeras restart/control u otras acciones sensibles salvo contexto muy explícito y directo. " + "Si el usuario pide explicar qué significa un estado como enabled, disabled, expired o consumed, usa explain_status. " + "Si llegas a proponer una mutación, que sea prudente, temporal y claramente una propuesta, no una recomendación fuerte. " + "reply_style debe ser brief. " + "needs_confirmation=true para mutaciones; false para lectura. " + f"Mensaje del operador: {text}" + ) + body = { + "system_instruction": { + "parts": [{"text": "Clasifica intención en JSON estricto para un asistente seguro de Telegram."}] + }, + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": { + "temperature": 0.1, + "responseMimeType": "application/json", + }, + } + raw = json.dumps(body).encode("utf-8") + request = urllib.request.Request(url, data=raw, method="POST") + request.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(request, timeout=self.settings.timeout_seconds) as response: + payload = json.loads(response.read().decode("utf-8", "replace") or "{}") + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + raise LLMAdapterError(f"llm request failed: {exc}") from exc + text_output = self._extract_text(payload) + try: + return json.loads(text_output) + except json.JSONDecodeError as exc: + raise LLMAdapterError("llm returned invalid json") from exc + + @staticmethod + def _extract_text(payload: dict[str, Any]) -> str: + candidates = payload.get("candidates") + if not isinstance(candidates, list) or not candidates: + raise LLMAdapterError("llm response has no candidates") + content = candidates[0].get("content", {}) + parts = content.get("parts") + if not isinstance(parts, list): + raise LLMAdapterError("llm response has no parts") + fragments: list[str] = [] + for part in parts: + if isinstance(part, dict) and isinstance(part.get("text"), str): + fragments.append(part["text"]) + text = "".join(fragments).strip() + if not text: + raise LLMAdapterError("llm response text is empty") + if text.startswith("```"): + lines = text.splitlines() + if len(lines) >= 3: + text = "\n".join(lines[1:-1]).strip() + return text diff --git a/scripts/agents/openclaw/restricted_operator/policy.py b/scripts/agents/openclaw/restricted_operator/policy.py index 4b471a8..5c81bd1 100644 --- a/scripts/agents/openclaw/restricted_operator/policy.py +++ b/scripts/agents/openclaw/restricted_operator/policy.py @@ -16,6 +16,7 @@ TelegramPrincipalRecord, WebhookTargetConfig, ) +from state_store import LockedJsonStateStore, StateStoreError class PolicyError(ValueError): @@ -64,7 +65,8 @@ def __init__(self, path: str | Path): self.operator_auth = self._load_operator_auth(self.raw.get("operator_auth", {})) self.telegram = self._load_telegram(self.raw.get("telegram", {})) self.state_store_path = Path(self.broker.state_store_path) - self.runtime_state = self._load_runtime_state(self.state_store_path) + self.runtime_store = LockedJsonStateStore(self.state_store_path) + self.runtime_state = self._load_runtime_state() @staticmethod def _load_json(path: Path) -> dict: @@ -253,15 +255,15 @@ def _load_telegram(payload: dict) -> TelegramConfig: ), ) - @staticmethod - def _load_runtime_state(path: Path) -> dict[str, dict]: - if not path.exists(): - return {} - payload = PolicyStore._load_json(path) - actions = payload.get("actions", {}) - if not isinstance(actions, dict): - raise PolicyError("runtime state actions must be an object") - return actions + def _load_runtime_state(self) -> dict[str, dict]: + try: + return self.runtime_store.load_actions() + except StateStoreError as exc: + raise PolicyError(str(exc)) from exc + + def refresh_runtime_state(self) -> dict[str, dict]: + self.runtime_state = self._load_runtime_state() + return self.runtime_state @staticmethod def validate_policy(path: str | Path) -> tuple[bool, list[str]]: @@ -276,12 +278,22 @@ def get_effective_action_state( action_id: str, *, now: datetime | None = None, + ) -> EffectiveActionState | None: + runtime_state = self.refresh_runtime_state() + return self._get_effective_action_state_from_runtime(action_id, runtime_state=runtime_state, now=now) + + def _get_effective_action_state_from_runtime( + self, + action_id: str, + *, + runtime_state: dict[str, dict], + now: datetime | None = None, ) -> EffectiveActionState | None: declared = self.actions.get(action_id) if declared is None: return None now = now or datetime.now(timezone.utc) - override = self.runtime_state.get(action_id, {}) + override = runtime_state.get(action_id, {}) enabled = bool(override.get("enabled", declared.enabled)) mode = str(override.get("mode", declared.mode)) expires_dt = parse_optional_datetime( @@ -320,9 +332,10 @@ def get_effective_action_state( ) def list_effective_action_states(self, *, now: datetime | None = None) -> list[EffectiveActionState]: + runtime_state = self.refresh_runtime_state() states: list[EffectiveActionState] = [] for action_id in sorted(self.actions): - state = self.get_effective_action_state(action_id, now=now) + state = self._get_effective_action_state_from_runtime(action_id, runtime_state=runtime_state, now=now) if state is not None: states.append(state) return states @@ -375,12 +388,15 @@ def consume_one_shot( reason: str = "consumed_after_valid_execution", ) -> None: used_at = used_at or datetime.now(timezone.utc) - action_state = self.runtime_state.setdefault(action_id, {}) - action_state["one_shot_consumed"] = True - action_state["consumed_at"] = used_at.isoformat().replace("+00:00", "Z") - action_state["updated_by"] = updated_by - action_state["reason"] = reason - self._persist_runtime_state() + self._mutate_runtime_state( + action_id, + lambda action_state: self._apply_one_shot_consumed( + action_state, + used_at=used_at, + updated_by=updated_by, + reason=reason, + ), + ) def mark_one_shot_used( self, @@ -403,12 +419,14 @@ def reset_one_shot( raise PolicyError(f"unknown action_id: {action_id}") if not declared.one_shot: raise PolicyError(f"action is not one_shot: {action_id}") - action_state = self.runtime_state.setdefault(action_id, {}) - action_state["one_shot_consumed"] = False - action_state.pop("consumed_at", None) - action_state["updated_by"] = updated_by - action_state["reason"] = reason - self._persist_runtime_state() + self._mutate_runtime_state( + action_id, + lambda action_state: self._apply_one_shot_reset( + action_state, + updated_by=updated_by, + reason=reason, + ), + ) def set_action_enabled( self, @@ -421,11 +439,15 @@ def set_action_enabled( declared = self.actions.get(action_id) if declared is None: raise PolicyError(f"unknown action_id: {action_id}") - action_state = self.runtime_state.setdefault(action_id, {}) - action_state["enabled"] = enabled - action_state["updated_by"] = updated_by - action_state["reason"] = reason or ("enabled_via_cli" if enabled else "disabled_via_cli") - self._persist_runtime_state() + self._mutate_runtime_state( + action_id, + lambda action_state: self._apply_enabled( + action_state, + enabled=enabled, + updated_by=updated_by, + reason=reason or ("enabled_via_cli" if enabled else "disabled_via_cli"), + ), + ) def set_action_enabled_with_expiration( self, @@ -439,16 +461,16 @@ def set_action_enabled_with_expiration( declared = self.actions.get(action_id) if declared is None: raise PolicyError(f"unknown action_id: {action_id}") - action_state = self.runtime_state.setdefault(action_id, {}) - action_state["enabled"] = enabled - action_state["expires_at"] = ( - expires_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - if expires_at is not None - else None + self._mutate_runtime_state( + action_id, + lambda action_state: self._apply_enabled_with_expiration( + action_state, + enabled=enabled, + expires_at=expires_at, + updated_by=updated_by, + reason=reason or ("enabled_with_expiration_via_cli" if enabled else "disabled_via_cli"), + ), ) - action_state["updated_by"] = updated_by - action_state["reason"] = reason or ("enabled_with_expiration_via_cli" if enabled else "disabled_via_cli") - self._persist_runtime_state() def set_action_expiration( self, @@ -461,16 +483,91 @@ def set_action_expiration( declared = self.actions.get(action_id) if declared is None: raise PolicyError(f"unknown action_id: {action_id}") - action_state = self.runtime_state.setdefault(action_id, {}) - if expires_at is None: - action_state["expires_at"] = None - else: - action_state["expires_at"] = expires_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + self._mutate_runtime_state( + action_id, + lambda action_state: self._apply_expiration( + action_state, + expires_at=expires_at, + updated_by=updated_by, + reason=reason or "updated_expiration_via_cli", + ), + ) + + def _mutate_runtime_state(self, action_id: str, mutator) -> None: + try: + self.runtime_state = self.runtime_store.update_actions( + lambda actions: mutator(actions.setdefault(action_id, {})) + ) + except StateStoreError as exc: + raise PolicyError(str(exc)) from exc + + @staticmethod + def _apply_one_shot_consumed( + action_state: dict[str, object], + *, + used_at: datetime, + updated_by: str, + reason: str, + ) -> None: + action_state["one_shot_consumed"] = True + action_state["consumed_at"] = used_at.isoformat().replace("+00:00", "Z") + action_state["updated_by"] = updated_by + action_state["reason"] = reason + + @staticmethod + def _apply_one_shot_reset( + action_state: dict[str, object], + *, + updated_by: str, + reason: str, + ) -> None: + action_state["one_shot_consumed"] = False + action_state.pop("consumed_at", None) + action_state["updated_by"] = updated_by + action_state["reason"] = reason + + @staticmethod + def _apply_enabled( + action_state: dict[str, object], + *, + enabled: bool, + updated_by: str, + reason: str, + ) -> None: + action_state["enabled"] = enabled action_state["updated_by"] = updated_by - action_state["reason"] = reason or "updated_expiration_via_cli" - self._persist_runtime_state() + action_state["reason"] = reason - def _persist_runtime_state(self) -> None: - self.state_store_path.parent.mkdir(parents=True, exist_ok=True) - payload = {"actions": self.runtime_state} - self.state_store_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + @staticmethod + def _apply_enabled_with_expiration( + action_state: dict[str, object], + *, + enabled: bool, + expires_at: datetime | None, + updated_by: str, + reason: str, + ) -> None: + action_state["enabled"] = enabled + action_state["expires_at"] = ( + expires_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if expires_at is not None + else None + ) + action_state["updated_by"] = updated_by + action_state["reason"] = reason + + @staticmethod + def _apply_expiration( + action_state: dict[str, object], + *, + expires_at: datetime | None, + updated_by: str, + reason: str, + ) -> None: + action_state["expires_at"] = ( + expires_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if expires_at is not None + else None + ) + action_state["updated_by"] = updated_by + action_state["reason"] = reason diff --git a/scripts/agents/openclaw/restricted_operator/state_store.py b/scripts/agents/openclaw/restricted_operator/state_store.py new file mode 100644 index 0000000..04c1f6b --- /dev/null +++ b/scripts/agents/openclaw/restricted_operator/state_store.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import fcntl +import json +import os +import tempfile +from collections.abc import Callable +from contextlib import contextmanager +from pathlib import Path +from typing import Any + + +class StateStoreError(ValueError): + pass + + +class LockedJsonStateStore: + def __init__(self, path: str | Path): + self.path = Path(path) + # The lock lives next to the JSON because os.replace swaps the target inode. + # Locking the data file itself would not reliably serialize writers across replacements. + self.lock_path = self.path.with_name(f"{self.path.name}.lock") + + def load(self) -> dict[str, Any]: + return self._read_unlocked() + + def load_actions(self) -> dict[str, dict[str, Any]]: + return self._extract_actions(self.load()) + + def update_actions( + self, + mutator: Callable[[dict[str, dict[str, Any]]], None], + ) -> dict[str, dict[str, Any]]: + def apply(payload: dict[str, Any]) -> None: + actions = self._extract_actions(payload) + mutator(actions) + payload["actions"] = actions + + payload = self.update_payload(apply) + return self._extract_actions(payload) + + def update_payload(self, mutator: Callable[[dict[str, Any]], None]) -> dict[str, Any]: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._exclusive_lock(): + payload = self._read_unlocked() + mutator(payload) + self._write_unlocked(payload) + return payload + + @contextmanager + def _exclusive_lock(self): + self.lock_path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + def _read_unlocked(self) -> dict[str, Any]: + if not self.path.exists(): + return {} + try: + with self.path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except FileNotFoundError: + return {} + except json.JSONDecodeError as exc: + raise StateStoreError(f"invalid runtime state json: {self.path}") from exc + if not isinstance(payload, dict): + raise StateStoreError("runtime state payload must be an object") + return payload + + @staticmethod + def _extract_actions(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: + actions = payload.get("actions", {}) + if not isinstance(actions, dict): + raise StateStoreError("runtime state actions must be an object") + return actions + + def _write_unlocked(self, payload: dict[str, Any]) -> None: + serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n" + tmp_path: Path | None = None + existing_mode = self._existing_mode() + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=self.path.parent, + prefix=f".{self.path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + tmp_path = Path(handle.name) + if existing_mode is not None: + os.fchmod(handle.fileno(), existing_mode) + handle.write(serialized) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, self.path) + self._fsync_directory() + finally: + if tmp_path is not None and tmp_path.exists(): + tmp_path.unlink() + + def _existing_mode(self) -> int | None: + try: + return self.path.stat().st_mode & 0o777 + except FileNotFoundError: + return None + + def _fsync_directory(self) -> None: + directory_fd = os.open(self.path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) diff --git a/scripts/agents/openclaw/restricted_operator/telegram_bot.py b/scripts/agents/openclaw/restricted_operator/telegram_bot.py index df3bcc3..43f067f 100644 --- a/scripts/agents/openclaw/restricted_operator/telegram_bot.py +++ b/scripts/agents/openclaw/restricted_operator/telegram_bot.py @@ -15,9 +15,13 @@ from typing import Any import unicodedata +import assistant_responses +from assistant_session import AssistantSessionStore from audit import AuditLogger from broker import RestrictedOperatorBroker import cli as broker_cli +from intent_router import IntentRouter +from llm_adapter import LLMAdapter from models import BrokerRequest, BrokerResult from policy import PolicyError, PolicyStore @@ -123,7 +127,12 @@ def check(self, principal_id: str) -> None: class TelegramCommandProcessor: - def __init__(self, policy_path: str, api_client: TelegramHttpClient | None = None): + def __init__( + self, + policy_path: str, + api_client: TelegramHttpClient | None = None, + llm_adapter: Any | None = None, + ): self.policy_path = policy_path self.policy = PolicyStore(policy_path) self.broker = RestrictedOperatorBroker(policy_path) @@ -139,6 +148,17 @@ def __init__(self, policy_path: str, api_client: TelegramHttpClient | None = Non token=token, ) self.pending_confirmations: dict[str, PendingConfirmation] = {} + self.session_store = AssistantSessionStore() + self.assistant_sessions = self.session_store.sessions + self.assistant_idle_timeout_seconds = max( + 60, + int(os.environ.get("OPENCLAW_TELEGRAM_ASSISTANT_IDLE_TIMEOUT_SECONDS", "300")), + ) + self.llm_adapter = llm_adapter or LLMAdapter() + self.intent_router = IntentRouter( + local_matcher=self._detect_conversational_intent, + llm_adapter=self.llm_adapter, + ) def process_update(self, update: dict[str, Any]) -> int | None: if isinstance(update.get("edited_message"), dict): @@ -251,7 +271,16 @@ def _handle_command( ok=True, result={"kind": "help"}, ) - return self._render_help(operator_id) + return assistant_responses.render_help(operator_id) + if command == "/wake": + return self._wake_assistant(chat_id=chat_id, user_id=user_id, operator_id=operator_id) + if command == "/sleep": + return self._sleep_assistant( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + reason="manual", + ) if command == "/status": return self._handle_status(chat_id=chat_id, user_id=user_id, operator_id=operator_id) @@ -279,9 +308,20 @@ def _handle_command( return "Comando no soportado. Usa /help." def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, text: str) -> str: + normalized = self._normalize_text(text) + if normalized in {"wake", "despierta", "despierta openclaw", "modo asistente"}: + return self._wake_assistant(chat_id=chat_id, user_id=user_id, operator_id=operator_id) + if normalized in {"sleep", "duerme", "duermete", "sal del modo asistente"}: + return self._sleep_assistant( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + reason="manual", + ) + + session = self._get_active_session(chat_id=chat_id, user_id=user_id, operator_id=operator_id) pending_key = self._pending_key(chat_id=chat_id, user_id=user_id) pending = self.pending_confirmations.get(pending_key) - normalized = self._normalize_text(text) if pending is not None and self._is_confirmation_accept(normalized): self.pending_confirmations.pop(pending_key, None) self._audit_channel_event( @@ -294,11 +334,19 @@ def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, action_id=pending.action_id, params={"intent": pending.intent, "summary": pending.summary}, ) - return self._execute_pending_confirmation( + return self._response( chat_id=chat_id, user_id=user_id, operator_id=operator_id, - pending=pending, + action_id=pending.action_id, + text=self._execute_pending_confirmation( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + pending=pending, + ), + mode="assistant" if session is not None else "conversation", + intent=pending.intent, ) if pending is not None and self._is_confirmation_reject(normalized): self.pending_confirmations.pop(pending_key, None) @@ -312,19 +360,71 @@ def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, action_id=pending.action_id, params={"intent": pending.intent, "summary": pending.summary}, ) - return "Acción cancelada. No se aplicó ningún cambio." + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id=pending.action_id, + text="Acción cancelada. No se aplicó ningún cambio.", + mode="assistant" if session is not None else "conversation", + intent=pending.intent, + ) if pending is not None: - return ( + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id=pending.action_id, + mode="assistant" if session is not None else "conversation", + intent="pending_confirmation", + text=( "Hay una acción pendiente de confirmación.\n" f"{pending.summary}\n" "Responde 'si' para ejecutar o 'no' para cancelar." ) + ) + + route = self.intent_router.route(text=text, assistant_awake=session is not None) + if route.llm_invoked: + self._audit_channel_event( + event="llm_invoked", + command="assistant", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=True, + action_id="telegram.command", + params={"text_preview": text[:120]}, + ) + if route.llm_validated and route.intent is not None: + self._audit_channel_event( + event="llm_output_validated", + command="assistant", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=True, + action_id=route.intent["action_id"], + params={"intent": route.intent["intent"]}, + ) + elif route.llm_rejected_reason is not None: + self._audit_channel_event( + event="llm_output_rejected", + command="assistant", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=False, + action_id="telegram.command", + error=route.llm_rejected_reason, + params={"text_preview": text[:120]}, + ) - intent = self._detect_conversational_intent(text) + intent = route.intent if intent is None: self._audit_channel_event( event="intent_rejected_unsupported", - command="conversation", + command="assistant" if session is not None else "conversation", chat_id=chat_id, user_id=user_id, operator_id=operator_id, @@ -332,11 +432,23 @@ def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, error="unsupported or ambiguous conversational intent", params={"text_preview": text[:120]}, ) - return self._render_conversation_help() + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant" if session is not None else "conversation", + intent="unsupported", + text=( + assistant_responses.render_assistant_fallback() + if session is not None + else assistant_responses.render_conversation_help() + ), + ) self._audit_channel_event( event="intent_detected", - command="conversation", + command="assistant" if session is not None else "conversation", chat_id=chat_id, user_id=user_id, operator_id=operator_id, @@ -345,11 +457,83 @@ def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, params={"intent": intent["intent"], "text_preview": text[:120]}, ) if intent["intent"] == "status": + if session is not None: + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="status", + text=self._render_assistant_status(chat_id=chat_id, user_id=user_id, operator_id=operator_id), + ) return self._handle_status(chat_id=chat_id, user_id=user_id, operator_id=operator_id) if intent["intent"] == "capabilities": + if session is not None: + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="capabilities", + text=self._render_assistant_capabilities( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ), + ) return self._handle_capabilities(chat_id=chat_id, user_id=user_id, operator_id=operator_id) if intent["intent"] == "audit_tail": + if session is not None: + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="audit_tail", + text=self._render_assistant_audit_tail( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ), + ) return self._handle_audit_tail(chat_id=chat_id, user_id=user_id, operator_id=operator_id) + if intent["intent"] == "explain_status": + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="explain_status", + text=self._render_assistant_explanation( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ), + ) + if intent["intent"] == "assistant_identity": + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="assistant_identity", + text=self._render_assistant_identity(operator_id=operator_id), + ) + if intent["intent"] == "suggest_action": + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="suggest_action", + text=self._render_assistant_suggestion(operator_id=operator_id), + ) if intent["intent"] == "logs_read": return self._execute_conversation_broker_action( chat_id=chat_id, @@ -358,6 +542,56 @@ def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, action_id="action.logs.read.v1", params=intent["params"], ) + if intent["intent"] == "unsupported": + self._audit_channel_event( + event="intent_rejected_unsupported", + command="assistant" if session is not None else "conversation", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=False, + error="llm returned unsupported intent", + params={"text_preview": text[:120]}, + ) + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant" if session is not None else "conversation", + intent="unsupported", + text=( + assistant_responses.render_assistant_fallback() + if session is not None + else assistant_responses.render_conversation_help() + ), + ) + + mutation_permission_error = self._check_mutation_permission( + operator_id=operator_id, + action_id=intent["action_id"], + ) + if mutation_permission_error is not None: + self._audit_channel_event( + event="intent_rejected_unauthorized", + command="assistant" if session is not None else "conversation", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=False, + action_id=intent["action_id"], + error=mutation_permission_error, + params={"intent": intent["intent"]}, + ) + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id=intent["action_id"], + mode="assistant" if session is not None else "conversation", + intent=intent["intent"], + text=f"No puedo proponer esa acción: {mutation_permission_error}", + ) pending = PendingConfirmation( intent=intent["intent"], @@ -371,7 +605,7 @@ def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, self.pending_confirmations[pending_key] = pending self._audit_channel_event( event="confirmation_requested", - command="conversation", + command="assistant" if session is not None else "conversation", chat_id=chat_id, user_id=user_id, operator_id=operator_id, @@ -379,11 +613,19 @@ def _handle_conversation(self, *, chat_id: str, user_id: str, operator_id: str, action_id=pending.action_id, params={"intent": pending.intent, "summary": pending.summary}, ) - return ( + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id=pending.action_id, + mode="assistant" if session is not None else "conversation", + intent=pending.intent, + text=( "Acción interpretada:\n" f"{pending.summary}\n" "Responde 'si' para ejecutar o 'no' para cancelar." ) + ) def _execute_conversation_broker_action( self, @@ -422,7 +664,15 @@ def _execute_conversation_broker_action( action_id=action_id, params=self._safe_params_for_audit(params), ) - return self._render_execution_result(result) + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id=action_id, + mode="assistant" if self._has_active_session(chat_id=chat_id, user_id=user_id, operator_id=operator_id) else "conversation", + intent="logs_read", + text=self._render_execution_result(result), + ) def _execute_pending_confirmation( self, @@ -474,7 +724,7 @@ def _execute_pending_confirmation( action_id=pending.action_id, params={"intent": pending.intent, "summary": pending.summary}, ) - return self._render_mutation_result( + return assistant_responses.render_mutation_result( ok=rc == 0, summary=pending.summary, action_id=pending.action_id, @@ -672,14 +922,23 @@ def _can_operator(self, operator_id: str, permission: str) -> bool: except PolicyError: return False + @staticmethod + def _session_key(*, chat_id: str, user_id: str) -> str: + return AssistantSessionStore.session_key(chat_id=chat_id, user_id=user_id) + @staticmethod def _pending_key(*, chat_id: str, user_id: str) -> str: return f"{chat_id}:{user_id}" + @staticmethod + def _now_monotonic() -> float: + return AssistantSessionStore.now_monotonic() + @staticmethod def _normalize_text(text: str) -> str: normalized = unicodedata.normalize("NFKD", text) normalized = "".join(ch for ch in normalized if not unicodedata.combining(ch)) + normalized = "".join(ch if not unicodedata.category(ch).startswith("P") else " " for ch in normalized) return " ".join(normalized.lower().strip().split()) @staticmethod @@ -690,10 +949,25 @@ def _is_confirmation_accept(normalized: str) -> bool: def _is_confirmation_reject(normalized: str) -> bool: return normalized in {"no", "cancelar", "cancela", "rechazar"} - def _detect_conversational_intent(self, text: str) -> dict[str, Any] | None: + def _detect_conversational_intent(self, text: str, *, assistant_awake: bool) -> dict[str, Any] | None: normalized = self._normalize_text(text) if normalized in {"estado", "estado general", "como va", "como va openclaw", "salud general"}: return {"intent": "status", "action_id": "telegram.command", "params": {}} + if assistant_awake and normalized in { + "como estamos", + "que tal estamos", + "estado del sistema", + "como esta todo", + "dame un resumen del estado", + }: + return {"intent": "status", "action_id": "telegram.command", "params": {}} + if assistant_awake and normalized in { + "quien eres", + "quien eres tu", + "que eres", + "presentate", + }: + return {"intent": "assistant_identity", "action_id": "telegram.command", "params": {}} if normalized in { "capacidades", "capacidades activas", @@ -702,8 +976,41 @@ def _detect_conversational_intent(self, text: str) -> dict[str, Any] | None: "capabilities", }: return {"intent": "capabilities", "action_id": "telegram.command", "params": {}} + if assistant_awake and normalized in { + "que puedes hacer", + "que tienes activo", + "que esta habilitado", + "que capacidades puedo usar", + }: + return {"intent": "capabilities", "action_id": "telegram.command", "params": {}} if normalized in {"auditoria", "auditoria reciente", "audit", "audit tail", "ultimos eventos"}: return {"intent": "audit_tail", "action_id": "telegram.command", "params": {}} + if assistant_awake and normalized in { + "que ha pasado", + "que paso recientemente", + "ultimos cambios", + "que hiciste", + }: + return {"intent": "audit_tail", "action_id": "telegram.command", "params": {}} + if assistant_awake and normalized in { + "explica el estado", + "explicame el estado", + "resume el estado", + "por que esta asi", + }: + return {"intent": "explain_status", "action_id": "telegram.command", "params": {}} + if assistant_awake and self._looks_like_conceptual_explain_status(normalized): + return {"intent": "explain_status", "action_id": "telegram.command", "params": {}} + if assistant_awake and normalized in { + "que propones", + "que recomiendas", + "propon una accion", + "propon acciones", + "que harias", + }: + return {"intent": "suggest_action", "action_id": "telegram.command", "params": {}} + if assistant_awake and self._looks_like_prudent_suggestion_request(normalized): + return {"intent": "suggest_action", "action_id": "telegram.command", "params": {}} logs_intent = self._match_logs_intent(normalized) if logs_intent is not None: @@ -723,6 +1030,17 @@ def _detect_conversational_intent(self, text: str) -> dict[str, Any] | None: return None + @staticmethod + def _looks_like_conceptual_explain_status(normalized: str) -> bool: + if "que significa" not in normalized and "explicame" not in normalized: + return False + return any(token in normalized for token in {"enabled", "disabled", "deshabilitada", "expirada", "expired", "consumida", "one shot"}) + + @staticmethod + def _looks_like_prudent_suggestion_request(normalized: str) -> bool: + suggestion_markers = {"que propones", "que recomiendas", "mejorar la operacion", "sin tocar nada sensible", "sin tocar nada", "prudente"} + return any(marker in normalized for marker in suggestion_markers) + def _match_logs_intent(self, normalized: str) -> dict[str, Any] | None: if "log" not in normalized: return None @@ -953,31 +1271,206 @@ def _render_mutation_result(*, ok: bool, summary: str, action_id: str) -> str: return f"Acción aplicada.\n{summary}\naction_id={action_id}" return f"No se pudo aplicar la acción.\n{summary}\naction_id={action_id}" - @staticmethod - def _render_conversation_help() -> str: - return ( - "No entendí la intención o no está soportada.\n" - "Prueba una de estas frases:\n" - "- estado general\n" - "- capacidades activas\n" - "- auditoría reciente\n" - "- logs openclaw 20\n" - "- habilita action.dropzone.write.v1\n" - "- deshabilita action.dropzone.write.v1\n" - "- habilita action.dropzone.write.v1 por 15 minutos\n" - "- resetea one-shot action.webhook.trigger.v1" + def _wake_assistant(self, *, chat_id: str, user_id: str, operator_id: str) -> str: + self.session_store.wake(chat_id=chat_id, user_id=user_id, operator_id=operator_id) + self._audit_channel_event( + event="assistant_wake", + command="/wake", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=True, + params={"timeout_seconds": self.assistant_idle_timeout_seconds}, + ) + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="assistant_wake", + text=( + "Asistente despierto.\n" + f"Timeout por inactividad: {self.assistant_idle_timeout_seconds}s.\n" + "Puedes pedirme estado general, capacidades activas, auditoría reciente, logs permitidos,\n" + "explicación del estado o propuestas de acción. Para salir: /sleep." + ), ) - @staticmethod - def _render_help(operator_id: str) -> str: - return ( - f"operator={operator_id}\n" - "/status\n" - "/capabilities\n" - "/audit_tail\n" - "/execute [k=v ...]\n" - "Conversacional: estado general | capacidades activas | auditoría reciente | logs openclaw 20" + def _sleep_assistant( + self, + *, + chat_id: str, + user_id: str, + operator_id: str, + reason: str, + ) -> str: + existed = self.session_store.sleep(chat_id=chat_id, user_id=user_id) + self.pending_confirmations.pop(self._pending_key(chat_id=chat_id, user_id=user_id), None) + if existed is not None: + self._audit_channel_event( + event="assistant_sleep", + command="/sleep", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=True, + params={"reason": reason}, + ) + return self._response( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + action_id="telegram.command", + mode="assistant", + intent="assistant_sleep", + text=( + "Asistente dormido. Mantengo disponibles los slash commands habituales." + if existed is not None + else "El asistente ya estaba dormido. Los slash commands siguen disponibles." + ), + ) + + def _get_active_session( + self, + *, + chat_id: str, + user_id: str, + operator_id: str, + ) -> AssistantSession | None: + def on_invalidated(reason: str) -> None: + self.pending_confirmations.pop(self._pending_key(chat_id=chat_id, user_id=user_id), None) + if reason == "timeout": + self._audit_channel_event( + event="assistant_sleep", + command="assistant", + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=True, + params={"reason": "timeout", "timeout_seconds": self.assistant_idle_timeout_seconds}, + ) + + return self.session_store.get_active( + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + idle_timeout_seconds=self.assistant_idle_timeout_seconds, + on_invalidated=on_invalidated, + ) + + def _has_active_session(self, *, chat_id: str, user_id: str, operator_id: str) -> bool: + return self.session_store.has_active(chat_id=chat_id, user_id=user_id, operator_id=operator_id) + + def _check_mutation_permission(self, *, operator_id: str, action_id: str) -> str | None: + try: + self.policy.authorize_operator_for_action_mutation(operator_id, action_id) + return None + except PolicyError as exc: + return str(exc) + + def _response( + self, + *, + chat_id: str, + user_id: str, + operator_id: str, + action_id: str, + text: str, + mode: str, + intent: str, + ) -> str: + self._audit_channel_event( + event="response_generated", + command=mode, + chat_id=chat_id, + user_id=user_id, + operator_id=operator_id, + ok=True, + action_id=action_id, + params={"mode": mode, "intent": intent}, + result={"text_preview": text[:200]}, + ) + return text + + def _render_assistant_status(self, *, chat_id: str, user_id: str, operator_id: str) -> str: + operator = self._authorize_operator( + operator_id=operator_id, + permission="policy.read", + command="assistant", + chat_id=chat_id, + user_id=user_id, + ) + if operator is None: + return "No puedo resumir el estado porque este operador no tiene permiso de lectura." + states = self.policy.list_effective_action_states() + summary = {"enabled": 0, "disabled": 0, "expired": 0, "consumed": 0} + for state in states: + summary[state.status] = summary.get(state.status, 0) + 1 + return assistant_responses.render_assistant_status( + operator_id=operator.operator_id, + total_actions=len(states), + summary=summary, + ) + + def _render_assistant_capabilities(self, *, chat_id: str, user_id: str, operator_id: str) -> str: + operator = self._authorize_operator( + operator_id=operator_id, + permission="policy.read", + command="assistant", + chat_id=chat_id, + user_id=user_id, ) + if operator is None: + return "No puedo listar capacidades porque este operador no tiene permiso de lectura." + rows = [] + for state in self.policy.list_effective_action_states(): + can_execute = self._can_operator(operator_id, state.permission) + rows.append( + f"- {state.action_id}: status={state.status}, mode={state.mode}, exec={'yes' if can_execute else 'no'}" + ) + return self._limit_message(assistant_responses.render_assistant_capabilities(rows=rows)) + + def _render_assistant_audit_tail(self, *, chat_id: str, user_id: str, operator_id: str) -> str: + body = self._handle_audit_tail(chat_id=chat_id, user_id=user_id, operator_id=operator_id) + if body == "Operador no autorizado para consultar auditoría.": + return body + return assistant_responses.render_assistant_audit_tail(body) + + def _render_assistant_explanation(self, *, chat_id: str, user_id: str, operator_id: str) -> str: + operator = self._authorize_operator( + operator_id=operator_id, + permission="policy.read", + command="assistant", + chat_id=chat_id, + user_id=user_id, + ) + if operator is None: + return "No puedo explicar el estado porque este operador no tiene permiso de lectura." + states = self.policy.list_effective_action_states() + disabled = [state.action_id for state in states if state.status == "disabled"] + consumed = [state.action_id for state in states if state.status == "consumed"] + expired = [state.action_id for state in states if state.status == "expired"] + return self._limit_message( + assistant_responses.render_assistant_explanation( + operator_id=operator.operator_id, + disabled=disabled, + expired=expired, + consumed=consumed, + ) + ) + + def _render_assistant_suggestion(self, *, operator_id: str) -> str: + states = self.policy.list_effective_action_states() + return assistant_responses.render_assistant_suggestion(operator_id=operator_id, states=states) + + @staticmethod + def _render_assistant_identity(*, operator_id: str) -> str: + return assistant_responses.render_assistant_identity(operator_id=operator_id) + + @staticmethod + def _render_assistant_fallback() -> str: + return assistant_responses.render_assistant_fallback() def build_logger(log_level: str) -> logging.Logger: diff --git a/scripts/console/davlos-vpn-console.sh b/scripts/console/davlos-vpn-console.sh index 3ebc7a8..067011f 100755 --- a/scripts/console/davlos-vpn-console.sh +++ b/scripts/console/davlos-vpn-console.sh @@ -8,7 +8,13 @@ OPENCLAW_RUNTIME_COMPOSE="${OPENCLAW_RUNTIME_ROOT}/compose/docker-compose.yaml" OPENCLAW_RUNTIME_LOG_DIR="${OPENCLAW_RUNTIME_ROOT}/logs" OPENCLAW_SECRETS_ROOT="/etc/davlos/secrets/openclaw" OPENCLAW_RUNTIME_MODELS_STATE="${OPENCLAW_RUNTIME_ROOT}/state/agents/main/agent/models.json" +OPENCLAW_BROKER_RUNTIME_ROOT="${OPENCLAW_RUNTIME_ROOT}/broker" +OPENCLAW_BROKER_RUNTIME_STATE="${OPENCLAW_BROKER_RUNTIME_ROOT}/state/restricted_operator_state.json" +OPENCLAW_BROKER_AUDIT_LOG="${OPENCLAW_BROKER_RUNTIME_ROOT}/audit/restricted_operator.jsonl" +OPENCLAW_TELEGRAM_RUNTIME_STATUS="${OPENCLAW_BROKER_RUNTIME_ROOT}/state/telegram_runtime_status.json" +OPENCLAW_READONLY_HELPER="/usr/local/sbin/davlos-openclaw-readonly" INFERENCE_GATEWAY_SERVICE="inference-gateway.service" +OPENCLAW_TELEGRAM_SERVICE="openclaw-telegram-bot.service" INFERENCE_GATEWAY_LOCAL_ENDPOINT="http://127.0.0.1:11440" INFERENCE_GATEWAY_AGENTS_ENDPOINT="http://172.22.0.1:11440/v1" OLLAMA_LOCAL_ENDPOINT="http://127.0.0.1:11434" @@ -72,9 +78,187 @@ subrule() { printf '%s\n' '----------------------------------------------------------------' } +panel_text_length() { + local text="$1" + printf '%s' "${#text}" +} + +panel_truncate_text() { + local text="$1" + local max_width="$2" + if (( max_width <= 0 )); then + printf '%s' "" + return + fi + if (( ${#text} <= max_width )); then + printf '%s' "${text}" + return + fi + if (( max_width <= 3 )); then + printf '%.*s' "${max_width}" "${text}" + return + fi + printf '%.*s...' "$((max_width - 3))" "${text}" +} + +panel_row() { + local left_plain="$1" + local right_plain="$2" + local total_width="$3" + local left_style="${4:-}" + local right_style="${5:-}" + local frame_style="${COLOR_BOLD}${COLOR_GREEN}" + local content_width left_len right_len padding max_left + local left_rendered right_rendered + + content_width=$((total_width - 4)) + right_len="$(panel_text_length "${right_plain}")" + if (( right_len > content_width - 1 )); then + right_plain="$(panel_truncate_text "${right_plain}" "$((content_width - 1))")" + right_len="$(panel_text_length "${right_plain}")" + fi + + left_len="$(panel_text_length "${left_plain}")" + if (( left_len + right_len > content_width )); then + max_left=$((content_width - right_len - 1)) + if (( max_left < 1 )); then + max_left=1 + fi + left_plain="$(panel_truncate_text "${left_plain}" "${max_left}")" + left_len="$(panel_text_length "${left_plain}")" + fi + + padding=$((content_width - left_len - right_len)) + if (( padding < 1 )); then + padding=1 + fi + + left_rendered="${left_plain}" + if [[ -n "${left_style}" ]]; then + left_rendered="${left_style}${left_plain}${COLOR_RESET}" + fi + + right_rendered="${right_plain}" + if [[ -n "${right_style}" ]]; then + right_rendered="${right_style}${right_plain}${COLOR_RESET}" + fi + + printf '%b║%b %s%*s%s %b║%b\n' "${frame_style}" "${COLOR_RESET}" "${left_rendered}" "${padding}" "" "${right_rendered}" "${frame_style}" "${COLOR_RESET}" +} + +panel_center_line() { + local text_plain="$1" + local total_width="$2" + local text_style="${3:-}" + local frame_style="${COLOR_BOLD}${COLOR_GREEN}" + local content_width text_len left_pad right_pad + local rendered_text + + content_width=$((total_width - 4)) + text_len="$(panel_text_length "${text_plain}")" + if (( text_len > content_width )); then + text_plain="$(panel_truncate_text "${text_plain}" "${content_width}")" + text_len="$(panel_text_length "${text_plain}")" + fi + + left_pad=$(((content_width - text_len) / 2)) + right_pad=$((content_width - text_len - left_pad)) + rendered_text="${text_plain}" + if [[ -n "${text_style}" ]]; then + rendered_text="${text_style}${text_plain}${COLOR_RESET}" + fi + + printf '%b║%b %*s%s%*s %b║%b\n' "${frame_style}" "${COLOR_RESET}" "${left_pad}" "" "${rendered_text}" "${right_pad}" "" "${frame_style}" "${COLOR_RESET}" +} + +panel_rule() { + local left_border="$1" + local fill_char="$2" + local right_border="$3" + local total_width="$4" + local frame_style="${COLOR_BOLD}${COLOR_GREEN}" + local fill + + printf -v fill '%*s' "$((total_width - 2))" '' + fill="${fill// /${fill_char}}" + printf '%b%s%s%s%b\n' "${frame_style}" "${left_border}" "${fill}" "${right_border}" "${COLOR_RESET}" +} + +panel_metric_cpu() { + local metric + metric="$(LC_ALL=C top -bn1 2>/dev/null | awk -F'[ ,]+' '/^%Cpu\\(s\\):/ {for (i = 1; i <= NF; i++) if ($i == "id") {printf "CPU: %.0f%%", 100 - $(i - 1); found = 1; exit}} END {if (!found) printf "CPU: N/A"}')" + if [[ -z "${metric}" || "${metric}" == "CPU: N/A" ]]; then + metric="$(awk '/^cpu / {idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; if (total > 0) {printf "CPU: %.0f%%", ((total - idle) / total) * 100; found = 1; exit}} END {if (!found) printf "CPU: N/A"}' /proc/stat 2>/dev/null)" + fi + if [[ -z "${metric}" ]]; then + metric="CPU: N/A" + fi + printf '%s' "${metric}" +} + +panel_metric_ram() { + local metric + metric="$(free -m 2>/dev/null | awk '/^Mem:/ && $2 > 0 {printf "RAM: %.0f%%", ($3 / $2) * 100; found = 1} END {if (!found) printf "RAM: N/A"}')" + if [[ -z "${metric}" ]]; then + metric="RAM: N/A" + fi + printf '%s' "${metric}" +} + +panel_metric_vpn_nodes() { + printf '%s' "Nodos VPN: 3 Activos" +} + +panel_terminal_width() { + local width="${COLUMNS:-}" + if [[ -z "${width}" && -n "${TERM:-}" ]] && command -v tput >/dev/null 2>&1; then + width="$(tput cols 2>/dev/null || true)" + fi + if ! [[ "${width}" =~ ^[0-9]+$ ]]; then + width=80 + fi + printf '%s' "${width}" +} + brand_block() { - printf '%b\n' "${COLOR_BOLD}${COLOR_BLUE}DAVLOS CONTROL-PLANE${COLOR_RESET}" - printf '%b\n' "${COLOR_BOLD}VPN Console MVP${COLOR_RESET}" + local panel_total_width=70 + local terminal_width + local madrid_time cpu_metric ram_metric vpn_metric combined_metric + local banner_lines=( + "██████╗ █████╗ ██╗ ██╗██╗ ██████╗ ███████╗" + "██╔══██╗██╔══██╗██║ ██║██║ ██╔═══██╗██╔════╝" + "██║ ██║███████║██║ ██║██║ ██║ ██║███████╗" + "██║ ██║██╔══██║╚██╗ ██╔╝██║ ██║ ██║╚════██║" + "██████╔╝██║ ██║ ╚████╔╝ ███████╗╚██████╔╝███████║" + "╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚══════╝" + ) + local banner_line + + terminal_width="$(panel_terminal_width)" + madrid_time="$(TZ="Europe/Madrid" date +%FT%T%z)" + cpu_metric="$(panel_metric_cpu)" + ram_metric="$(panel_metric_ram)" + vpn_metric="$(panel_metric_vpn_nodes)" + combined_metric="${cpu_metric} | ${ram_metric}" + + if (( terminal_width < 72 )); then + printf '%b%s%b %b%s%b\n' "${COLOR_BOLD}${COLOR_WHITE}" "DAVLOS VPN Console MVP" "${COLOR_RESET}" "${COLOR_BOLD}${COLOR_GREEN}" "[ ONLINE ]" "${COLOR_RESET}" + printf ' Repo: %s\n' "${REPO_ROOT}" + printf ' Hora: %s\n' "${madrid_time}" + printf ' %s | %s\n' "${cpu_metric}" "${ram_metric}" + printf ' %s\n' "${vpn_metric}" + return + fi + + panel_rule "╔" "═" "╗" "${panel_total_width}" + for banner_line in "${banner_lines[@]}"; do + panel_center_line "${banner_line}" "${panel_total_width}" "${COLOR_BOLD}${COLOR_WHITE}" + done + panel_rule "╠" "═" "╣" "${panel_total_width}" + panel_row "VPN Console MVP" "[ ONLINE ]" "${panel_total_width}" "" "${COLOR_BOLD}${COLOR_GREEN}" + panel_row "Repo: ${REPO_ROOT}" "${combined_metric}" "${panel_total_width}" + panel_row "Time: ${madrid_time}" "${vpn_metric}" "${panel_total_width}" + panel_rule "╚" "═" "╝" "${panel_total_width}" } print_section_title() { @@ -109,12 +293,18 @@ notice_line() { } print_header() { + if [[ -t 1 && -n "${TERM:-}" && "${TERM}" != "dumb" ]]; then + clear + fi printf '\n' - rule brand_block - subrule - kv_line "repo" "${REPO_ROOT}" - kv_line "timestamp" "$(date -u +%FT%TZ)" + + local current_user + current_user="$(whoami)" + if [[ "${current_user}" != "devops" && "${current_user}" != "root" ]]; then + printf '\n' + notice_line warning "Ejecutando como '${current_user}'. Los datos reales requieren lanzar esto como 'devops'." + fi printf '\n' } @@ -185,10 +375,75 @@ openclaw_broker_policy_path() { return 1 } +openclaw_broker_policy_source() { + if [[ -r "${OPENCLAW_RESTRICTED_OPERATOR_POLICY_RUNTIME}" ]]; then + printf '%s\n' "runtime" + return 0 + fi + if [[ -r "${OPENCLAW_RESTRICTED_OPERATOR_POLICY_REPO}" ]]; then + printf '%s\n' "repo_fallback" + return 0 + fi + printf '%s\n' "unavailable" +} + +openclaw_runtime_summary_field() { + local field="$1" + local helper_output + if ! openclaw_readonly_helper_available; then + return 1 + fi + helper_output="$(run_openclaw_readonly_helper runtime_summary 2>/dev/null || true)" + printf '%s\n' "${helper_output}" | sed -n "s/^${field}=//p" | head -n 1 +} + +openclaw_policy_source_display() { + local value + value="$(openclaw_runtime_summary_field policy_source 2>/dev/null || true)" + if [[ -n "${value}" ]]; then + printf '%s\n' "${value}" + return 0 + fi + openclaw_broker_policy_source +} + +openclaw_policy_path_display() { + local value + value="$(openclaw_runtime_summary_field policy_path 2>/dev/null || true)" + if [[ -n "${value}" ]]; then + printf '%s\n' "${value}" + return 0 + fi + openclaw_broker_policy_path 2>/dev/null || printf '%s\n' "unavailable" +} + openclaw_broker_cli_available() { command -v python3 >/dev/null 2>&1 && [[ -r "${OPENCLAW_RESTRICTED_OPERATOR_CLI}" ]] } +openclaw_readonly_helper_available() { + [[ -f "${OPENCLAW_READONLY_HELPER}" ]] && sudo -n "${OPENCLAW_READONLY_HELPER}" runtime_summary >/dev/null 2>&1 +} + +run_openclaw_readonly_helper() { + if ! [[ -f "${OPENCLAW_READONLY_HELPER}" ]]; then + echo "Helper readonly de OpenClaw no instalado en el host." >&2 + return 1 + fi + sudo -n "${OPENCLAW_READONLY_HELPER}" "$@" +} + +runtime_access_badge() { + local path="$1" + if [[ -r "${path}" ]]; then + printf '%s %s\n' "$(badge success yes)" "direct" + elif openclaw_readonly_helper_available; then + printf '%s %s\n' "$(badge success yes)" "via_helper" + else + printf '%s %s\n' "$(badge warning no)" "unavailable" + fi +} + openclaw_operator_identity() { if [[ -n "${OPENCLAW_OPERATOR_SESSION_ID}" ]]; then printf '%s\n' "${OPENCLAW_OPERATOR_SESSION_ID}" @@ -201,6 +456,33 @@ openclaw_operator_identity() { printf '%s\n' "${USER:-unknown}" } +service_active_state() { + local service="$1" + if ! command -v systemctl >/dev/null 2>&1; then + printf '%s\n' "unknown" + return 0 + fi + systemctl is-active "${service}" 2>/dev/null || true +} + +service_sub_state() { + local service="$1" + if ! command -v systemctl >/dev/null 2>&1; then + printf '%s\n' "unknown" + return 0 + fi + systemctl show -p SubState --value "${service}" 2>/dev/null || true +} + +service_main_pid() { + local service="$1" + if ! command -v systemctl >/dev/null 2>&1; then + printf '%s\n' "unknown" + return 0 + fi + systemctl show -p MainPID --value "${service}" 2>/dev/null || true +} + resolve_openclaw_operator_identity() { local default_operator operator default_operator="$(openclaw_operator_identity)" @@ -341,7 +623,7 @@ show_network_status() { print_section_title "Red / listeners / puertos clave" echo print_subsection "listeners clave" - if ! ss -lntp 2>/dev/null | grep -E ':(22|80|81|443|5678|51820|11434)\b'; then + if ! ss -lntp 2>/dev/null | grep -E ':(22|80|81|443|5678|51820|11434|11440)\b'; then notice_line warning "No se pudieron listar los puertos clave desde esta sesion." fi } @@ -361,11 +643,19 @@ show_evidence_paths() { "${REPO_ROOT}/docs/MVP_PHASE_5_AGENT_ZONE.md" \ "${REPO_ROOT}/docs/MVP_PHASE_6_INFERENCE_GATEWAY.md" \ "${REPO_ROOT}/docs/MVP_PHASE_8_RESTRICTED_OPERATOR.md" \ + "${REPO_ROOT}/docs/TELEGRAM_OPENCLAW_MVP.md" \ + "${REPO_ROOT}/docs/TELEGRAM_OPENCLAW_RUNTIME_MVP.md" \ + "${REPO_ROOT}/docs/OPENCLAW_OPERATOR_FLOWS_MVP.md" \ "${REPO_ROOT}/docs/AGENT_ZONE_SECURITY_MVP.md" \ "${REPO_ROOT}/docs/AGENT_ZONE_EGRESS_ALLOWLIST_MVP.md" \ "${REPO_ROOT}/runbooks/OPENCLAW_DEPLOY_MVP.md" \ "${REPO_ROOT}/runbooks/OPENCLAW_ROLLBACK_MVP.md" \ - "${REPO_ROOT}/evidence/agents/OPENCLAW_MVP_VALIDATION_2026-03-31.md" + "${REPO_ROOT}/evidence/agents/OPENCLAW_MVP_VALIDATION_2026-03-31.md" \ + "${REPO_ROOT}/docs/reports/OPENCLAW_SECURITY_REGRESSION_FIX_2026-04-01.md" \ + "${REPO_ROOT}/docs/reports/OPENCLAW_PHASE_8_HARDENING_MVP_2026-04-01.md" \ + "${REPO_ROOT}/docs/reports/OPENCLAW_PHASE_9_TIMEBOXED_HARDENING_2026-04-01.md" \ + "${REPO_ROOT}/docs/reports/OPENCLAW_PHASE_10_CONSOLE_POLISH_2026-04-01.md" \ + "${REPO_ROOT}/docs/reports/OPENCLAW_PHASE_11_OPERATOR_FLOWS_2026-04-01.md" echo print_subsection "ultimos ficheros de evidencia" find "${REPO_ROOT}/evidence" -maxdepth 3 -type f -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort | tail -n 12 @@ -459,9 +749,12 @@ show_openclaw_status() { echo show_inference_gateway_summary echo + show_openclaw_telegram_summary + echo print_subsection "control basico previsto" notice_line readonly "status/logs/health visibles en consola" - notice_line warning "start/stop/restart no habilitados en este MVP readonly" + notice_line info "Telegram runtime visible en una vista dedicada cuando systemd y el runtime lo permiten" + notice_line warning "start/stop/restart no habilitados en esta consola MVP" } show_openclaw_logs() { @@ -531,16 +824,61 @@ show_openclaw_health() { show_inference_gateway_summary } -show_openclaw_capabilities() { +show_openclaw_telegram_summary() { + local active_state="unknown" + local sub_state="unknown" + local main_pid="unknown" + + print_subsection "telegram runtime" + if command -v systemctl >/dev/null 2>&1; then + active_state="$(systemctl is-active "${OPENCLAW_TELEGRAM_SERVICE}" 2>/dev/null || true)" + sub_state="$(systemctl show -p SubState --value "${OPENCLAW_TELEGRAM_SERVICE}" 2>/dev/null || true)" + main_pid="$(systemctl show -p MainPID --value "${OPENCLAW_TELEGRAM_SERVICE}" 2>/dev/null || true)" + kv_line "service" "${OPENCLAW_TELEGRAM_SERVICE}" + kv_line "active_state" "${active_state:-unknown}" + kv_line "sub_state" "${sub_state:-unknown}" + kv_line "main_pid" "${main_pid:-unknown}" + else + kv_line "systemctl_not_available" "$(badge warning yes)" + fi + kv_line "runtime_status_path" "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}" + kv_line "runtime_status_access" "$(runtime_access_badge "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}")" + if [[ ! -r "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}" ]] && ! openclaw_readonly_helper_available; then + notice_line warning "telegram_runtime_status.json no visible desde esta sesion ni via helper readonly." + fi + kv_line "commands" "/status /capabilities /audit_tail /execute" +} + +show_openclaw_telegram_runtime() { + local helper_output print_header - print_section_title "OpenClaw / capacidades" + print_section_title "OpenClaw / Telegram runtime" echo - if policy_path="$(openclaw_broker_policy_path 2>/dev/null)"; then - kv_line "policy_path" "${policy_path}" + show_openclaw_telegram_summary + if [[ -r "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}" ]]; then + echo + print_subsection "ultimo estado runtime" + sed -n '1,160p' "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}" | redact_sensitive_output + elif helper_output="$(run_openclaw_readonly_helper telegram_runtime_status 2>/dev/null)"; then + echo + print_subsection "ultimo estado runtime (via helper readonly)" + printf '%s\n' "${helper_output}" | redact_sensitive_output else - kv_line "policy_path" "$(badge warning unavailable)" + echo + notice_line warning "El estado runtime de Telegram no es legible desde esta sesion." fi echo + notice_line readonly "La ejecucion real de comandos sigue ocurriendo via bot, policy y auditoria del broker." +} + +show_openclaw_capabilities() { + local cli_output rc=0 helper_output + print_header + print_section_title "OpenClaw / capacidades" + echo + kv_line "policy_source" "$(openclaw_policy_source_display)" + kv_line "policy_path" "$(openclaw_policy_path_display)" + echo if ! openclaw_broker_cli_available; then notice_line error "CLI del broker no disponible desde esta sesion." return 0 @@ -551,7 +889,22 @@ show_openclaw_capabilities() { [MUTATING] restricted: accion mutante o sensible EOF echo - if ! run_openclaw_broker_cli show --format console; then + cli_output="$(run_openclaw_broker_cli show --format console 2>&1)" || rc=$? + if [[ "${rc}" -eq 0 ]]; then + printf '%s\n' "${cli_output}" | redact_sensitive_output + elif printf '%s' "${cli_output}" | grep -q 'PermissionError:'; then + if helper_output="$(run_openclaw_readonly_helper broker_state_console 2>/dev/null)"; then + echo + notice_line readonly "Usando helper readonly root-owned para leer el runtime real." + printf '%s\n' "${helper_output}" | redact_sensitive_output + else + echo + notice_line error "No se pudo leer el estado efectivo del broker desde esta sesion." + kv_line "runtime_state" "${OPENCLAW_BROKER_RUNTIME_STATE}" + notice_line warning "El estado runtime del broker existe pero no es legible con los permisos actuales." + fi + else + printf '%s\n' "${cli_output}" | redact_sensitive_output echo notice_line error "No se pudo leer el estado efectivo del broker desde esta sesion." notice_line warning "Posible causa: policy no visible o permisos insuficientes." @@ -559,6 +912,7 @@ EOF } show_openclaw_capabilities_audit() { + local cli_output rc=0 helper_output print_header print_section_title "OpenClaw / auditoria de capacidades" echo @@ -568,11 +922,494 @@ show_openclaw_capabilities_audit() { fi notice_line readonly "Solo lectura; por Telegram, /audit_tail puede quedar reservado a admin." echo - if ! run_openclaw_broker_cli audit-tail --lines 20 --format console | redact_sensitive_output; then + cli_output="$(run_openclaw_broker_cli audit-tail --lines 20 --format console 2>&1)" || rc=$? + if [[ "${rc}" -eq 0 ]]; then + printf '%s\n' "${cli_output}" | redact_sensitive_output + elif printf '%s' "${cli_output}" | grep -q 'PermissionError:'; then + if helper_output="$(run_openclaw_readonly_helper broker_audit_recent 2>/dev/null)"; then + notice_line readonly "Usando helper readonly root-owned para leer auditoria reciente." + printf '%s\n' "${helper_output}" | redact_sensitive_output + else + notice_line error "No se pudo leer la auditoria del broker desde esta sesion." + kv_line "runtime_state" "${OPENCLAW_BROKER_RUNTIME_STATE}" + notice_line warning "La auditoria vive en el runtime del broker y no es legible con los permisos actuales." + fi + else + printf '%s\n' "${cli_output}" | redact_sensitive_output notice_line error "No se pudo leer la auditoria del broker desde esta sesion." fi } +known_action_label() { + case "$1" in + action.health.general.v1) printf '%s\n' "Health general" ;; + action.logs.read.v1) printf '%s\n' "Lectura de logs" ;; + action.webhook.trigger.v1) printf '%s\n' "Disparo controlado" ;; + action.openclaw.restart.v1) printf '%s\n' "Reinicio OpenClaw" ;; + action.dropzone.write.v1) printf '%s\n' "Escritura drop-zone" ;; + *) printf '%s\n' "$1" ;; + esac +} + +known_action_permission() { + case "$1" in + action.health.general.v1|action.logs.read.v1) printf '%s\n' "operator.read" ;; + action.webhook.trigger.v1) printf '%s\n' "operator.trigger" ;; + action.openclaw.restart.v1) printf '%s\n' "operator.control" ;; + action.dropzone.write.v1) printf '%s\n' "operator.write" ;; + *) printf '%s\n' "unknown" ;; + esac +} + +known_action_description() { + case "$1" in + action.health.general.v1) printf '%s\n' "Health general fijo del boundary OpenClaw/inference-gateway." ;; + action.logs.read.v1) printf '%s\n' "Lectura controlada de logs permitidos por stream_id." ;; + action.webhook.trigger.v1) printf '%s\n' "Disparo de webhook controlado para operaciones acotadas." ;; + action.openclaw.restart.v1) printf '%s\n' "Reservado a control administrativo; sigue siendo la accion mas sensible." ;; + action.dropzone.write.v1) printf '%s\n' "Escritura limitada en la drop-zone controlada del broker." ;; + *) printf '%s\n' "Accion no catalogada en esta consola." ;; + esac +} + +known_action_badge() { + case "$1" in + action.health.general.v1|action.logs.read.v1) badge readonly "READONLY" ;; + action.openclaw.restart.v1) badge warning "CONTROL" ;; + *) badge mutating "RESTRICTED" ;; + esac +} + +print_known_action_card() { + local action_id="$1" + printf '%s %s\n' "$(known_action_badge "${action_id}")" "$(known_action_label "${action_id}")" + kv_line "action_id" "${action_id}" + kv_line "permission" "$(known_action_permission "${action_id}")" + kv_line "descripcion" "$(known_action_description "${action_id}")" + echo +} + +show_openclaw_action_catalog() { + print_header + print_section_title "OpenClaw / catalogo de acciones" + echo + print_subsection "acciones conocidas en el boundary actual" + print_known_action_card "action.health.general.v1" + print_known_action_card "action.logs.read.v1" + print_known_action_card "action.webhook.trigger.v1" + print_known_action_card "action.dropzone.write.v1" + print_known_action_card "action.openclaw.restart.v1" + notice_line info "Las acciones readonly forman parte de la observabilidad base." + notice_line warning "Las acciones restricted/control deben abrirse con TTL corto y motivo claro." +} + +show_operational_overview() { + local docker_status cli_status helper_status + print_header + print_section_title "Resumen operativo" + echo + if docker_available; then + docker_status="$(badge success yes)" + else + docker_status="$(badge warning no)" + fi + if openclaw_broker_cli_available; then + cli_status="$(badge success yes)" + else + cli_status="$(badge warning no)" + fi + if openclaw_readonly_helper_available; then + helper_status="$(badge success yes)" + else + helper_status="$(badge warning no)" + fi + print_subsection "estado ejecutivo" + kv_line "host" "$(hostname 2>/dev/null || printf '%s' 'unknown')" + kv_line "session_operator" "$(openclaw_operator_identity)" + kv_line "docker_access" "${docker_status}" + kv_line "broker_cli_available" "${cli_status}" + kv_line "readonly_helper" "${helper_status}" + kv_line "policy_source" "$(openclaw_policy_source_display)" + kv_line "policy_path" "$(openclaw_policy_path_display)" + echo + print_subsection "servicios clave" + kv_line "inference_gateway" "$(service_active_state "${INFERENCE_GATEWAY_SERVICE}") / $(service_sub_state "${INFERENCE_GATEWAY_SERVICE}")" + kv_line "telegram_bot" "$(service_active_state "${OPENCLAW_TELEGRAM_SERVICE}") / $(service_sub_state "${OPENCLAW_TELEGRAM_SERVICE}")" + kv_line "openclaw_runtime_root" "$([[ -d "${OPENCLAW_RUNTIME_ROOT}" ]] && badge success yes || badge warning no)" + kv_line "broker_state_access" "$(runtime_access_badge "${OPENCLAW_BROKER_RUNTIME_STATE}")" + kv_line "broker_audit_access" "$(runtime_access_badge "${OPENCLAW_BROKER_AUDIT_LOG}")" + kv_line "telegram_runtime_access" "$(runtime_access_badge "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}")" + echo + print_subsection "superficie funcional" + notice_line readonly "Health y logs forman parte de la observabilidad base." + notice_line info "Telegram esta integrado como canal operativo corto: /status /capabilities /audit_tail /execute." + notice_line mutating "Broker y capacidades permiten mutacion controlada via policy/TTL." + if openclaw_readonly_helper_available; then + notice_line success "El helper readonly del host esta disponible para leer runtime real sin abrir acceso general." + fi + echo + print_subsection "siguiente comprobacion sugerida" + if [[ -r "${OPENCLAW_BROKER_RUNTIME_STATE}" ]] || openclaw_readonly_helper_available; then + echo "- Revisar estado efectivo del broker y auditoria." + else + echo "- Resolver acceso readonly al runtime del broker para ver estado y auditoria real." + fi + if [[ "$(service_active_state "${OPENCLAW_TELEGRAM_SERVICE}")" == "active" ]] && { [[ -r "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}" ]] || openclaw_readonly_helper_available; }; then + echo "- Validar Telegram runtime y ultimos eventos si se abre acceso a logs/runtime status." + else + echo "- Revisar Telegram service antes de habilitar controles mutantes." + fi +} + +show_openclaw_runtime_diagnostics() { + local cli_status docker_status systemctl_status helper_status + print_header + print_section_title "OpenClaw / diagnostico operativo" + echo + if openclaw_broker_cli_available; then + cli_status="$(badge success yes)" + else + cli_status="$(badge warning no)" + fi + if docker_available; then + docker_status="$(badge success yes)" + else + docker_status="$(badge warning no)" + fi + if command -v systemctl >/dev/null 2>&1; then + systemctl_status="$(badge success yes)" + else + systemctl_status="$(badge warning no)" + fi + if openclaw_readonly_helper_available; then + helper_status="$(badge success yes)" + else + helper_status="$(badge warning no)" + fi + print_subsection "sesion actual" + kv_line "operator_id_sugerido" "$(openclaw_operator_identity)" + kv_line "broker_cli_available" "${cli_status}" + kv_line "docker_access" "${docker_status}" + kv_line "systemctl_available" "${systemctl_status}" + kv_line "readonly_helper" "${helper_status}" + echo + print_subsection "runtime y permisos" + kv_line "runtime_root" "${OPENCLAW_RUNTIME_ROOT}" + kv_line "runtime_root_exists" "$([[ -d "${OPENCLAW_RUNTIME_ROOT}" ]] && badge success yes || badge warning no)" + kv_line "compose_readable" "$([[ -r "${OPENCLAW_RUNTIME_COMPOSE}" ]] && badge success yes || badge warning no)" + kv_line "policy_runtime_readable" "$([[ -r "${OPENCLAW_RESTRICTED_OPERATOR_POLICY_RUNTIME}" ]] && badge success yes || badge warning no)" + kv_line "broker_state_access" "$(runtime_access_badge "${OPENCLAW_BROKER_RUNTIME_STATE}")" + kv_line "broker_audit_access" "$(runtime_access_badge "${OPENCLAW_BROKER_AUDIT_LOG}")" + kv_line "telegram_runtime_access" "$(runtime_access_badge "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}")" + echo + print_subsection "servicios" + kv_line "inference_gateway" "$(service_active_state "${INFERENCE_GATEWAY_SERVICE}") / pid=$(service_main_pid "${INFERENCE_GATEWAY_SERVICE}")" + kv_line "telegram_bot" "$(service_active_state "${OPENCLAW_TELEGRAM_SERVICE}") / pid=$(service_main_pid "${OPENCLAW_TELEGRAM_SERVICE}")" + echo + print_subsection "degradaciones detectadas" + if [[ ! -r "${OPENCLAW_BROKER_RUNTIME_STATE}" ]] && ! openclaw_readonly_helper_available; then + notice_line warning "La consola no puede leer el estado vivo del broker con los permisos actuales." + fi + if [[ ! -r "${OPENCLAW_BROKER_AUDIT_LOG}" ]] && ! openclaw_readonly_helper_available; then + notice_line warning "La auditoria real del broker no es legible desde esta sesion." + fi + if [[ ! -r "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}" ]] && ! openclaw_readonly_helper_available; then + notice_line warning "telegram_runtime_status.json no es legible; solo se ve el estado systemd." + fi + if [[ -r "${OPENCLAW_BROKER_RUNTIME_STATE}" && -r "${OPENCLAW_BROKER_AUDIT_LOG}" && -r "${OPENCLAW_TELEGRAM_RUNTIME_STATUS}" ]]; then + notice_line success "La sesion tiene visibilidad readonly suficiente sobre runtime y auditoria." + elif openclaw_readonly_helper_available; then + notice_line success "El helper readonly del host compensa la falta de lectura directa sobre runtime." + fi +} + +select_mutating_action_id() { + local action_choice custom_action + if [[ ! -t 0 ]]; then + prompt_optional 'action_id' + return 0 + fi + if ! inline_menu_choice_with_fallback "seleccion de accion mutante" MUTATING_ACTION_OPTIONS show_mutating_action_menu "Selecciona una accion: " action_choice; then + return 1 + fi + case "${action_choice}" in + 1) printf '%s\n' "action.webhook.trigger.v1" ;; + 2) printf '%s\n' "action.dropzone.write.v1" ;; + 3) printf '%s\n' "action.openclaw.restart.v1" ;; + 8) + custom_action="$(prompt_optional 'action_id')" + if [[ -z "${custom_action}" ]]; then + return 1 + fi + printf '%s\n' "${custom_action}" + ;; + 9) return 1 ;; + *) + echo "Opcion no valida." + return 1 + ;; + esac +} + +show_mutating_action_menu() { + print_subsection "seleccion de accion mutante" + menu_line "1" "Disparo controlado" "$(badge mutating "action.webhook.trigger.v1")" + menu_line "2" "Escritura drop-zone" "$(badge mutating "action.dropzone.write.v1")" + menu_line "3" "Reinicio OpenClaw" "$(badge warning "action.openclaw.restart.v1")" + menu_line "8" "Introducir action_id manual" + menu_line "9" "Cancelar" +} + +security_preset_title() { + case "$1" in + readonly-strict) printf '%s\n' "Observacion estricta" ;; + trigger-window) printf '%s\n' "Disparo controlado" ;; + dropzone-window) printf '%s\n' "Escritura temporal" ;; + operator-temporal) printf '%s\n' "Operador temporal" ;; + admin-window) printf '%s\n' "Admin restringido" ;; + *) printf '%s\n' "$1" ;; + esac +} + +security_preset_default_ttl() { + case "$1" in + readonly-strict) printf '%s\n' "" ;; + trigger-window) printf '%s\n' "10" ;; + dropzone-window) printf '%s\n' "15" ;; + operator-temporal) printf '%s\n' "15" ;; + admin-window) printf '%s\n' "10" ;; + *) printf '%s\n' "" ;; + esac +} + +security_preset_warning() { + case "$1" in + readonly-strict) + printf '%s\n' "Reduce la superficie mutante a solo observabilidad. No afecta acciones readonly." + ;; + trigger-window) + printf '%s\n' "Abre solo el disparo controlado. Recomendado para ventanas muy cortas." + ;; + dropzone-window) + printf '%s\n' "Abre solo la escritura en drop-zone. Mantiene webhook y restart cerrados." + ;; + operator-temporal) + printf '%s\n' "Abre la operativa habitual de operador con TTL corto. Restart sigue cerrado." + ;; + admin-window) + printf '%s\n' "Abre tambien restart. Usar solo con operador admin y motivo fuerte." + ;; + *) + printf '%s\n' "Preset sin advertencia catalogada." + ;; + esac +} + +security_preset_plan() { + case "$1" in + readonly-strict) + printf '%s\n' \ + "disable|action.webhook.trigger.v1" \ + "disable|action.dropzone.write.v1" \ + "disable|action.openclaw.restart.v1" + ;; + trigger-window) + printf '%s\n' \ + "enable_ttl|action.webhook.trigger.v1" \ + "disable|action.dropzone.write.v1" \ + "disable|action.openclaw.restart.v1" + ;; + dropzone-window) + printf '%s\n' \ + "disable|action.webhook.trigger.v1" \ + "enable_ttl|action.dropzone.write.v1" \ + "disable|action.openclaw.restart.v1" + ;; + operator-temporal) + printf '%s\n' \ + "enable_ttl|action.webhook.trigger.v1" \ + "enable_ttl|action.dropzone.write.v1" \ + "disable|action.openclaw.restart.v1" + ;; + admin-window) + printf '%s\n' \ + "enable_ttl|action.webhook.trigger.v1" \ + "enable_ttl|action.dropzone.write.v1" \ + "enable_ttl|action.openclaw.restart.v1" + ;; + *) + return 1 + ;; + esac +} + +show_security_presets_catalog() { + print_header + print_section_title "OpenClaw / seguridad / presets" + echo + print_subsection "presets disponibles" + menu_line "1" "Observacion estricta" "$(badge readonly "LOCKDOWN")" + echo " Solo observabilidad. Deshabilita webhook, drop-zone y restart." + menu_line "2" "Disparo controlado" "$(badge mutating "TTL 10m")" + echo " Abre solo action.webhook.trigger.v1 durante una ventana corta." + menu_line "3" "Escritura temporal" "$(badge mutating "TTL 15m")" + echo " Abre solo action.dropzone.write.v1 con TTL corto." + menu_line "4" "Operador temporal" "$(badge mutating "TTL 15m")" + echo " Abre webhook + drop-zone para operativa habitual; restart sigue cerrado." + menu_line "5" "Admin restringido" "$(badge warning "TTL 10m")" + echo " Abre tambien restart. Debe usarlo un operador con permisos admin." + echo + notice_line warning "Todos los presets mutantes exigen confirmacion explicita y dejan rastro en auditoria." +} + +show_security_preset_preview() { + local preset="$1" + local operator="$2" + local reason="$3" + local ttl_minutes="$4" + local operation action_id + + print_header + print_section_title "OpenClaw / seguridad / previsualizacion" + echo + kv_line "preset" "$(security_preset_title "${preset}")" + kv_line "operator_id" "${operator}" + kv_line "reason" "${reason}" + if [[ -n "${ttl_minutes}" ]]; then + kv_line "ttl_minutes" "${ttl_minutes}" + fi + echo + print_subsection "advertencia" + notice_line warning "$(security_preset_warning "${preset}")" + echo + print_subsection "cambios previstos" + while IFS='|' read -r operation action_id; do + [[ -z "${operation}" ]] && continue + case "${operation}" in + enable_ttl) + echo "- ENABLE TTL ${ttl_minutes}m :: $(known_action_label "${action_id}") (${action_id})" + ;; + disable) + echo "- DISABLE :: $(known_action_label "${action_id}") (${action_id})" + ;; + esac + done < <(security_preset_plan "${preset}") + echo + notice_line info "La consola aplicara cada cambio via broker CLI y mostrara el resultado paso a paso." +} + +confirm_security_apply() { + local phrase + if [[ ! -t 0 ]]; then + notice_line warning "Sesion no interactiva: la aplicacion del preset se cancela por seguridad." + return 1 + fi + printf 'Escribe APPLY para continuar: ' + read -r phrase + if [[ "${phrase}" != "APPLY" ]]; then + notice_line warning "Operacion cancelada; no se aplico ningun cambio." + return 1 + fi + return 0 +} + +apply_security_preset_flow() { + local preset="$1" + local operator reason ttl_minutes default_ttl operation action_id failures=0 + local change_reason rollback_reason rollback_action rollback_failures=0 + local successful_rollbacks=() + + if ! openclaw_broker_cli_available; then + notice_line error "CLI del broker no disponible desde esta sesion." + return 1 + fi + + operator="$(resolve_openclaw_operator_identity)" || return 1 + default_ttl="$(security_preset_default_ttl "${preset}")" + if [[ -n "${default_ttl}" ]]; then + ttl_minutes="$(prompt_with_default 'ttl_minutes' "${default_ttl}")" + if [[ -z "${ttl_minutes}" ]]; then + echo "ttl_minutes requerido." + return 1 + fi + else + ttl_minutes="" + fi + reason="$(prompt_with_default 'motivo' "preset_$(printf '%s' "${preset}" | tr '-' '_')")" + show_security_preset_preview "${preset}" "${operator}" "${reason}" "${ttl_minutes}" + if ! confirm_security_apply; then + return 1 + fi + + while IFS='|' read -r operation action_id; do + [[ -z "${operation}" ]] && continue + printf '\n' + print_subsection "$(known_action_label "${action_id}")" + change_reason="${reason}:${preset}" + case "${operation}" in + enable_ttl) + if apply_openclaw_capability_change \ + "$(security_preset_title "${preset}")" \ + enable --action-id "${action_id}" --ttl-minutes "${ttl_minutes}" --operator-id "${operator}" --reason "${change_reason}"; then + successful_rollbacks+=("${action_id}") + else + failures=$((failures + 1)) + fi + ;; + enable) + if apply_openclaw_capability_change \ + "$(security_preset_title "${preset}")" \ + enable --action-id "${action_id}" --operator-id "${operator}" --reason "${change_reason}"; then + successful_rollbacks+=("${action_id}") + else + failures=$((failures + 1)) + fi + ;; + disable) + if ! apply_openclaw_capability_change \ + "$(security_preset_title "${preset}")" \ + disable --action-id "${action_id}" --operator-id "${operator}" --reason "${change_reason}"; then + failures=$((failures + 1)) + fi + ;; + *) + notice_line error "Operacion de preset no soportada: ${operation} (${action_id})" + failures=$((failures + 1)) + ;; + esac + done < <(security_preset_plan "${preset}") + + printf '\n' + if [[ "${failures}" -eq 0 ]]; then + notice_line success "Preset aplicado correctamente: $(security_preset_title "${preset}")" + return 0 + fi + + notice_line error "Preset fallido: ${failures} cambios no se pudieron completar." + if [[ "${#successful_rollbacks[@]}" -gt 0 ]]; then + notice_line warning "Se detecto una apertura parcial. Ejecutando rollback automatico por seguridad." + rollback_reason="${reason}:${preset}:auto_rollback" + for rollback_action in "${successful_rollbacks[@]}"; do + printf '\n' + print_subsection "rollback :: $(known_action_label "${rollback_action}")" + if ! apply_openclaw_capability_change \ + "$(security_preset_title "${preset}") / rollback" \ + disable --action-id "${rollback_action}" --operator-id "${operator}" --reason "${rollback_reason}"; then + rollback_failures=$((rollback_failures + 1)) + fi + done + printf '\n' + if [[ "${rollback_failures}" -eq 0 ]]; then + notice_line warning "Rollback automatico completado. Se revirtieron las aperturas aplicadas por el preset." + else + notice_line error "Rollback automatico incompleto: ${rollback_failures} cambios no se pudieron revertir." + fi + else + notice_line warning "No hubo aperturas exitosas que revertir automaticamente." + fi + return 1 +} + prompt_with_default() { local prompt="$1" local default_value="$2" @@ -624,7 +1461,7 @@ apply_openclaw_capability_change() { openclaw_capability_enable_flow() { local action_id reason operator - action_id="$(prompt_optional 'action_id')" + action_id="$(select_mutating_action_id)" || return 1 if [[ -z "${action_id}" ]]; then echo "action_id requerido." return 1 @@ -637,7 +1474,7 @@ openclaw_capability_enable_flow() { openclaw_capability_disable_flow() { local action_id reason operator - action_id="$(prompt_optional 'action_id')" + action_id="$(select_mutating_action_id)" || return 1 if [[ -z "${action_id}" ]]; then echo "action_id requerido." return 1 @@ -650,7 +1487,7 @@ openclaw_capability_disable_flow() { openclaw_capability_ttl_flow() { local action_id ttl_minutes reason operator - action_id="$(prompt_optional 'action_id')" + action_id="$(select_mutating_action_id)" || return 1 if [[ -z "${action_id}" ]]; then echo "action_id requerido." return 1 @@ -668,7 +1505,7 @@ openclaw_capability_ttl_flow() { openclaw_capability_reset_one_shot_flow() { local action_id reason operator - action_id="$(prompt_optional 'action_id')" + action_id="$(select_mutating_action_id)" || return 1 if [[ -z "${action_id}" ]]; then echo "action_id requerido." return 1 @@ -684,62 +1521,312 @@ show_help() { print_section_title "Ayuda / limites del MVP" echo cat <<'EOF' -[READONLY] Esta consola es principalmente readonly. -[MUTATING] El submenu de capacidades mezcla lectura y mutacion controlada. -- Lectura: ver estado y auditoria. -- Mutacion: enable/disable/ttl/reset-one-shot, siempre via CLI/policy. -[WARNING] No reinicia servicios, no toca secretos y no modifica produccion. -- Si una comprobacion requiere Docker o sudo y no esta disponible, muestra un aviso y sigue. -- La fuente de verdad operativa actual esta en README.md y evidence/. -- El inventario funcional minimo de workflows de n8n sigue en estado PARTIAL por acceso readonly limitado al runtime activo. -- OpenClaw e inference-gateway se presentan en modo readonly usando Docker/systemd/journal si estan disponibles en la sesion. -- El submenu de capacidades OpenClaw usa la CLI del broker; si no hay permisos suficientes o el operador no esta autorizado, degrada con mensaje claro. -[WARNING] Start/stop/restart quedan fuera de esta consola MVP. +[READONLY] La consola prioriza observabilidad, diagnostico y verificacion operativa. +[MUTATING] La mutacion existe solo en broker/capacidades y seguridad/control. +- Dashboard inicial para situacion general del host, OpenClaw, broker y Telegram. +- Menus separados para runtime, broker, seguridad, evidencias y diagnostico. +- Presets guiados de seguridad con TTL corto, motivo obligatorio y confirmacion explicita. +[WARNING] No toca secretos ni reinicia servicios directamente desde esta sesion. +- Si una comprobacion requiere Docker o sudo y no esta disponible, la consola degrada con mensaje claro. +- Si el helper readonly root-owned esta instalado, la consola puede leer runtime real de broker y Telegram sin acceso general a /opt/automation. +- Telegram se trata como canal operativo real: status service, runtime status y comandos soportados. +- Broker y capacidades usan la CLI real del restricted operator; no se inventan acciones fuera de policy. +- La fuente de verdad operativa sigue siendo runtime + auditoria; la plantilla del repo es fallback declarativo. +[WARNING] Para acciones sensibles: usar TTL corto, motivo claro y revisar auditoria despues. EOF } +interactive_menu_render() { + local title="$1" + local options_name="$2" + local selected_index="$3" + local -n options_ref="${options_name}" + local option_id option_label + local index + local active_cursor="${COLOR_BOLD}${COLOR_CYAN}❯${COLOR_RESET}" + local active_label_style="${COLOR_BOLD}${COLOR_WHITE}" + + printf '\r\033[2K%b%s%b\n' "${COLOR_BOLD}${COLOR_WHITE}" "${title}" "${COLOR_RESET}" > /dev/tty + printf '\r\033[2K\n' > /dev/tty + for index in "${!options_ref[@]}"; do + IFS='|' read -r option_id option_label <<< "${options_ref[${index}]}" + if [[ "${index}" -eq "${selected_index}" ]]; then + printf '\r\033[2K %b %b%s%b\n' "${active_cursor}" "${active_label_style}" "${option_label}" "${COLOR_RESET}" > /dev/tty + else + printf '\r\033[2K %s\n' "${option_label}" > /dev/tty + fi + done + printf '\r\033[2K\n' > /dev/tty + printf '\r\033[2K%b%s%b\n' "${COLOR_DIM}" "Usa ↑/↓ y Enter para seleccionar." "${COLOR_RESET}" > /dev/tty +} + +interactive_menu() { + local title="$1" + local options_name="$2" + local result_var="${3:-INTERACTIVE_MENU_RESULT}" + local -n options_ref="${options_name}" + local selected_index=0 + local rendered_lines + local key escape_1 escape_2 + local selected_id selected_label + + if [[ ! -t 0 || ! -t 1 || ! -r /dev/tty ]]; then + return 1 + fi + if [[ "${#options_ref[@]}" -eq 0 ]]; then + return 1 + fi + + rendered_lines=$(( ${#options_ref[@]} + 4 )) + printf '\033[?25l' > /dev/tty + interactive_menu_render "${title}" "${options_name}" "${selected_index}" + + while true; do + if ! IFS= read -rsn1 key < /dev/tty; then + printf '\033[?25h' > /dev/tty + return 1 + fi + + case "${key}" in + "") + IFS='|' read -r selected_id selected_label <<< "${options_ref[${selected_index}]}" + printf '\033[?25h' > /dev/tty + printf -v "${result_var}" '%s' "${selected_id}" + return 0 + ;; + $'\x1b') + escape_1="" + escape_2="" + IFS= read -rsn1 -t 0.05 escape_1 < /dev/tty || true + if [[ "${escape_1}" == "[" ]]; then + IFS= read -rsn1 -t 0.05 escape_2 < /dev/tty || true + case "${escape_2}" in + A) + selected_index=$(( (selected_index - 1 + ${#options_ref[@]}) % ${#options_ref[@]} )) + ;; + B) + selected_index=$(( (selected_index + 1) % ${#options_ref[@]} )) + ;; + esac + fi + ;; + k) + selected_index=$(( (selected_index - 1 + ${#options_ref[@]}) % ${#options_ref[@]} )) + ;; + j) + selected_index=$(( (selected_index + 1) % ${#options_ref[@]} )) + ;; + *) + continue + ;; + esac + + printf '\033[%dA' "${rendered_lines}" > /dev/tty + interactive_menu_render "${title}" "${options_name}" "${selected_index}" + done +} + +menu_choice_with_fallback() { + local title="$1" + local options_name="$2" + local fallback_fn="$3" + local prompt="$4" + local result_var="${5:-MENU_CHOICE}" + local selection + + if [[ -t 0 && -t 1 ]]; then + print_header + if interactive_menu "${title}" "${options_name}" "${result_var}"; then + return 0 + fi + fi + + "${fallback_fn}" + printf '%s' "${prompt}" + read -r selection + printf -v "${result_var}" '%s' "${selection}" +} + +inline_menu_choice_with_fallback() { + local title="$1" + local options_name="$2" + local fallback_fn="$3" + local prompt="$4" + local result_var="${5:-MENU_CHOICE}" + local selection + + if [[ -t 0 && -t 1 ]]; then + if interactive_menu "${title}" "${options_name}" "${result_var}"; then + return 0 + fi + fi + + "${fallback_fn}" + printf '%s' "${prompt}" + read -r selection + printf -v "${result_var}" '%s' "${selection}" +} + show_menu() { print_header - print_section_title "Menu principal" + print_section_title " [ MENÚ PRINCIPAL ]" echo - menu_line "1" "Estado general del host" - menu_line "2" "Estado de Docker" - menu_line "3" "Estado de n8n" - menu_line "4" "Red / listeners / puertos clave" - menu_line "5" "Ultimas evidencias / ruta de control-plane" - menu_line "6" "Zona de agentes" - menu_line "7" "OpenClaw / inference-gateway" - menu_line "8" "Ayuda / limites del MVP" - menu_line "9" "Salir" + menu_line "1" "📊 Resumen operativo" + menu_line "2" "🌐 OpenClaw y Telegram" + menu_line "3" "⚙️ Broker y capacidades" + menu_line "4" "🔒 Seguridad y control" + menu_line "5" "📑 Evidencias e informes" + menu_line "6" "🛠️ Diagnostico" + menu_line "7" "❓ Ayuda / limites del MVP" + echo + menu_line "9" "🚪 Salir" + echo + subrule + printf '%s\n' "$(badge readonly "READONLY") observabilidad, evidencias y diagnostico" + printf '%s\n' "$(badge mutating "MUTATING") broker/capacidades y presets de seguridad" + subrule +} + +declare -a MAIN_MENU_OPTIONS +declare -a OPENCLAW_MENU_OPTIONS +declare -a BROKER_MENU_OPTIONS +declare -a SECURITY_MENU_OPTIONS +declare -a DIAGNOSTICS_MENU_OPTIONS +declare -a OPENCLAW_CAPABILITIES_MENU_OPTIONS +declare -a MUTATING_ACTION_OPTIONS + +init_menu_options() { + MAIN_MENU_OPTIONS=( + "1|📊 Resumen operativo" + "2|🌐 OpenClaw y Telegram" + "3|⚙️ Broker y capacidades" + "4|🔒 Seguridad y control" + "5|📑 Evidencias e informes" + "6|🛠️ Diagnostico" + "7|❓ Ayuda / limites del MVP" + "9|🚪 Salir" + ) + + OPENCLAW_MENU_OPTIONS=( + "1|Resumen runtime OpenClaw" + "2|Telegram runtime" + "3|Logs utiles" + "4|Health" + "5|Catalogo de acciones" + "9|Volver" + ) + + BROKER_MENU_OPTIONS=( + "1|Estado efectivo" + "2|Auditoria reciente" + "3|Catalogo de acciones" + "4|Control manual por accion $(badge mutating "GUIADO")" + "5|Diagnostico broker/runtime" + "9|Volver" + ) + + SECURITY_MENU_OPTIONS=( + "1|Ver catalogo de presets $(badge readonly "READONLY")" + "2|Aplicar observacion estricta $(badge readonly "LOCKDOWN")" + "3|Aplicar disparo controlado $(badge mutating "TTL 10m")" + "4|Aplicar escritura temporal $(badge mutating "TTL 15m")" + "5|Aplicar operador temporal $(badge mutating "TTL 15m")" + "6|Aplicar admin restringido $(badge warning "TTL 10m")" + "7|Resetear one-shot manual $(badge mutating "MANUAL")" + "8|Diagnostico broker/runtime $(badge readonly "READONLY")" + "9|Volver" + ) + + DIAGNOSTICS_MENU_OPTIONS=( + "1|Resumen operativo" + "2|Estado general del host" + "3|Estado de Docker" + "4|Red / listeners / puertos clave" + "5|Zona de agentes" + "6|Diagnostico OpenClaw / broker" + "7|Estado de n8n" + "8|Evidencias e informes" + "9|Volver" + ) + + OPENCLAW_CAPABILITIES_MENU_OPTIONS=( + "1|Ver estado efectivo $(badge readonly "READONLY")" + "2|Habilitar accion $(badge mutating "MUTATING")" + "3|Deshabilitar accion $(badge mutating "MUTATING")" + "4|Habilitar accion con TTL $(badge mutating "MUTATING")" + "5|Resetear one-shot consumido $(badge mutating "MUTATING")" + "6|Ver auditoria reciente $(badge readonly "READONLY")" + "7|Catalogo de acciones $(badge readonly "READONLY")" + "8|Diagnostico broker/runtime $(badge readonly "READONLY")" + "9|Volver" + ) + + MUTATING_ACTION_OPTIONS=( + "1|Disparo controlado $(badge mutating "action.webhook.trigger.v1")" + "2|Escritura drop-zone $(badge mutating "action.dropzone.write.v1")" + "3|Reinicio OpenClaw $(badge warning "action.openclaw.restart.v1")" + "8|Introducir action_id manual" + "9|Cancelar" + ) +} + +show_openclaw_menu() { + print_header + print_section_title "OpenClaw y Telegram" echo - printf '%s\n' "$(badge readonly "READONLY") inspeccion y observabilidad" - printf '%s\n' "$(badge mutating "MUTATING") solo aparece en capacidades OpenClaw" + menu_line "1" "Resumen runtime OpenClaw" + menu_line "2" "Telegram runtime" + menu_line "3" "Logs utiles" + menu_line "4" "Health" + menu_line "5" "Catalogo de acciones" + menu_line "9" "Volver" } -show_agents_menu() { +show_broker_menu() { print_header - print_section_title "Zona de agentes" + print_section_title "Broker y capacidades" echo - menu_line "1" "Resumen de la zona" - menu_line "2" "Seguridad y allowlist" + menu_line "1" "Estado efectivo" + menu_line "2" "Auditoria reciente" + menu_line "3" "Catalogo de acciones" + menu_line "4" "Control manual por accion" "$(badge mutating "GUIADO")" + menu_line "5" "Diagnostico broker/runtime" menu_line "9" "Volver" } -show_openclaw_menu() { +show_security_menu() { print_header - print_section_title "OpenClaw / inference-gateway" + print_section_title "Seguridad y control" echo - menu_line "1" "Estado MVP" - menu_line "2" "Logs utiles" - menu_line "3" "Health" - menu_line "4" "Control basico previsto" - menu_line "5" "Capacidades OpenClaw" "$(badge mutating "READ + MUTATE")" + menu_line "1" "Ver catalogo de presets" "$(badge readonly "READONLY")" + menu_line "2" "Aplicar observacion estricta" "$(badge readonly "LOCKDOWN")" + menu_line "3" "Aplicar disparo controlado" "$(badge mutating "TTL 10m")" + menu_line "4" "Aplicar escritura temporal" "$(badge mutating "TTL 15m")" + menu_line "5" "Aplicar operador temporal" "$(badge mutating "TTL 15m")" + menu_line "6" "Aplicar admin restringido" "$(badge warning "TTL 10m")" + menu_line "7" "Resetear one-shot manual" "$(badge mutating "MANUAL")" + menu_line "8" "Diagnostico broker/runtime" "$(badge readonly "READONLY")" + menu_line "9" "Volver" +} + +show_diagnostics_menu() { + print_header + print_section_title "Diagnostico" + echo + menu_line "1" "Resumen operativo" + menu_line "2" "Estado general del host" + menu_line "3" "Estado de Docker" + menu_line "4" "Red / listeners / puertos clave" + menu_line "5" "Zona de agentes" + menu_line "6" "Diagnostico OpenClaw / broker" + menu_line "7" "Estado de n8n" + menu_line "8" "Evidencias e informes" menu_line "9" "Volver" } show_openclaw_capabilities_menu() { print_header - print_section_title "OpenClaw / capacidades" + print_section_title "Broker / control manual" echo menu_line "1" "Ver estado efectivo" "$(badge readonly "READONLY")" menu_line "2" "Habilitar accion" "$(badge mutating "MUTATING")" @@ -747,20 +1834,18 @@ show_openclaw_capabilities_menu() { menu_line "4" "Habilitar accion con TTL" "$(badge mutating "MUTATING")" menu_line "5" "Resetear one-shot consumido" "$(badge mutating "MUTATING")" menu_line "6" "Ver auditoria reciente" "$(badge readonly "READONLY")" + menu_line "7" "Catalogo de acciones" "$(badge readonly "READONLY")" + menu_line "8" "Diagnostico broker/runtime" "$(badge readonly "READONLY")" menu_line "9" "Volver" } -run_agents_section() { +run_openclaw_section() { case "$1" in - 1|summary) show_agents_zone ;; - 2|security) - print_header - print_section_title "Zona de agentes: seguridad y allowlist" - printf '\n' - sed -n '1,220p' "${REPO_ROOT}/docs/AGENT_ZONE_SECURITY_MVP.md" - printf '\n' - sed -n '1,220p' "${REPO_ROOT}/docs/AGENT_ZONE_EGRESS_ALLOWLIST_MVP.md" - ;; + 1|status) show_openclaw_status ;; + 2|telegram) show_openclaw_telegram_runtime ;; + 3|logs) show_openclaw_logs ;; + 4|health) show_openclaw_health ;; + 5|actions) show_openclaw_action_catalog ;; 9|back) return 1 ;; *) echo "Opcion no valida: $1" >&2 @@ -769,29 +1854,14 @@ run_agents_section() { esac } -run_openclaw_section() { +run_broker_section() { case "$1" in - 1|status) show_openclaw_status ;; - 2|logs) show_openclaw_logs ;; - 3|health) show_openclaw_health ;; - 4|controls) - print_header - print_section_title "OpenClaw / inference-gateway: control basico previsto" - printf '\n' - cat <<'EOF' -[READONLY] status: visible en la consola -[READONLY] logs: visibles en la consola -[READONLY] health: visible en la consola -[READONLY] inference-gateway host: visible en la consola -[WARNING] start/stop/restart: no habilitados en este MVP readonly -- despliegue y rollback: definidos en runbooks del control-plane -EOF - ;; - 5|capabilities) + 1|show) show_openclaw_capabilities ;; + 2|audit) show_openclaw_capabilities_audit ;; + 3|catalog) show_openclaw_action_catalog ;; + 4|manual) while true; do - show_openclaw_capabilities_menu - printf 'Selecciona una opcion: ' - read -r openclaw_cap_choice + menu_choice_with_fallback "Broker / control manual" OPENCLAW_CAPABILITIES_MENU_OPTIONS show_openclaw_capabilities_menu "Selecciona una opcion: " openclaw_cap_choice case "${openclaw_cap_choice}" in 1) show_openclaw_capabilities ;; 2) openclaw_capability_enable_flow ;; @@ -799,6 +1869,8 @@ EOF 4) openclaw_capability_ttl_flow ;; 5) openclaw_capability_reset_one_shot_flow ;; 6) show_openclaw_capabilities_audit ;; + 7) show_openclaw_action_catalog ;; + 8) show_openclaw_runtime_diagnostics ;; 9) break ;; *) echo "Opcion no valida: ${openclaw_cap_choice}" >&2 @@ -807,6 +1879,7 @@ EOF pause_if_interactive done ;; + 5|diag) show_openclaw_runtime_diagnostics ;; 9|back) return 1 ;; *) echo "Opcion no valida: $1" >&2 @@ -815,18 +1888,60 @@ EOF esac } -run_section() { +run_security_section() { case "$1" in - 1|host) show_host_status ;; - 2|docker) show_docker_status ;; - 3|n8n) show_n8n_status ;; + 1|catalog) show_security_presets_catalog ;; + 2|readonly-strict) apply_security_preset_flow "readonly-strict" ;; + 3|trigger-window) apply_security_preset_flow "trigger-window" ;; + 4|dropzone-window) apply_security_preset_flow "dropzone-window" ;; + 5|operator-temporal) apply_security_preset_flow "operator-temporal" ;; + 6|admin-window) apply_security_preset_flow "admin-window" ;; + 7|reset-one-shot) openclaw_capability_reset_one_shot_flow ;; + 8|diag) show_openclaw_runtime_diagnostics ;; + 9|back) return 1 ;; + *) + echo "Opcion no valida: $1" >&2 + return 2 + ;; + esac +} + +run_diagnostics_section() { + case "$1" in + 1|overview) show_operational_overview ;; + 2|host) show_host_status ;; + 3|docker) show_docker_status ;; 4|network|ports) show_network_status ;; + 5|agents) show_agents_zone ;; + 6|openclaw) show_openclaw_runtime_diagnostics ;; + 7|n8n) show_n8n_status ;; + 8|evidence) show_evidence_paths ;; + 9|back) return 1 ;; + *) + echo "Opcion no valida: $1" >&2 + return 2 + ;; + esac +} + +run_section() { + case "$1" in + 1|overview|summary) show_operational_overview ;; + 2|openclaw) show_openclaw_status ;; + 3|broker|openclaw-broker) show_openclaw_capabilities ;; + 4|security|openclaw-security) show_security_presets_catalog ;; 5|evidence) show_evidence_paths ;; - 6|agents) show_agents_zone ;; - 7|openclaw) show_openclaw_status ;; + 6|diagnostics|openclaw-diagnostics) show_openclaw_runtime_diagnostics ;; + 7|help) show_help ;; + host) show_host_status ;; + docker) show_docker_status ;; + n8n) show_n8n_status ;; + network|ports) show_network_status ;; + agents) show_agents_zone ;; + openclaw-telegram) show_openclaw_telegram_runtime ;; openclaw-capabilities) show_openclaw_capabilities ;; openclaw-capabilities-audit) show_openclaw_capabilities_audit ;; - 8|help) show_help ;; + openclaw-actions) show_openclaw_action_catalog ;; openclaw-logs) show_openclaw_logs ;; openclaw-health) show_openclaw_health ;; 9|exit) exit 0 ;; @@ -838,6 +1953,7 @@ run_section() { } init_console_style +init_menu_options if [[ -n "${SECTION}" ]]; then run_section "${SECTION}" @@ -845,32 +1961,48 @@ if [[ -n "${SECTION}" ]]; then fi while true; do - show_menu - printf 'Selecciona una opcion: ' - read -r choice + menu_choice_with_fallback " [ MENÚ PRINCIPAL ]" MAIN_MENU_OPTIONS show_menu "Selecciona una opcion: " choice case "${choice}" in - 6) + 2) while true; do - show_agents_menu - printf 'Selecciona una opcion: ' - read -r agents_choice - if ! run_agents_section "${agents_choice}"; then + menu_choice_with_fallback "OpenClaw y Telegram" OPENCLAW_MENU_OPTIONS show_openclaw_menu "Selecciona una opcion: " openclaw_choice + if ! run_openclaw_section "${openclaw_choice}"; then break fi pause_if_interactive done ;; - 7) + 3) while true; do - show_openclaw_menu - printf 'Selecciona una opcion: ' - read -r openclaw_choice - if ! run_openclaw_section "${openclaw_choice}"; then + menu_choice_with_fallback "Broker y capacidades" BROKER_MENU_OPTIONS show_broker_menu "Selecciona una opcion: " broker_choice + if ! run_broker_section "${broker_choice}"; then break fi pause_if_interactive done ;; + 4) + while true; do + menu_choice_with_fallback "Seguridad y control" SECURITY_MENU_OPTIONS show_security_menu "Selecciona una opcion: " security_choice + if ! run_security_section "${security_choice}"; then + break + fi + pause_if_interactive + done + ;; + 6) + while true; do + menu_choice_with_fallback "Diagnostico" DIAGNOSTICS_MENU_OPTIONS show_diagnostics_menu "Selecciona una opcion: " diagnostics_choice + if ! run_diagnostics_section "${diagnostics_choice}"; then + break + fi + pause_if_interactive + done + ;; + 7) + show_help + pause_if_interactive + ;; 9) echo "Saliendo." exit 0 diff --git a/templates/openclaw/davlos-openclaw-readonly.sh b/templates/openclaw/davlos-openclaw-readonly.sh new file mode 100644 index 0000000..0656813 --- /dev/null +++ b/templates/openclaw/davlos-openclaw-readonly.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly DATE_BIN="/usr/bin/date" +readonly JOURNALCTL_BIN="/usr/bin/journalctl" +readonly PYTHON_BIN="/usr/bin/python3" + +readonly RUNTIME_POLICY_PATH="/opt/automation/agents/openclaw/broker/restricted_operator_policy.json" +readonly FALLBACK_POLICY_PATH="/opt/control-plane/templates/openclaw/restricted_operator_policy.json" +readonly DEFAULT_AUDIT_LINES="20" +readonly DEFAULT_UNIT_LOG_LINES="6" + +usage() { + echo "Usage: $0 {runtime_summary|broker_state_console|broker_audit_recent|telegram_runtime_status|operational_logs_recent}" >&2 + exit 64 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + echo "ERROR: this helper must run as root" >&2 + exit 1 + fi +} + +require_deps() { + for bin in "${DATE_BIN}" "${JOURNALCTL_BIN}" "${PYTHON_BIN}"; do + if [[ ! -x "${bin}" ]]; then + echo "ERROR: missing required binary: ${bin}" >&2 + exit 1 + fi + done +} + +print_header() { + local mode="$1" + echo "== davlos openclaw readonly ==" + echo "mode=${mode}" + echo "timestamp=$("${DATE_BIN}" -u +%FT%TZ)" +} + +run_python_helper() { + local mode="$1" + "${PYTHON_BIN}" - "${mode}" "${RUNTIME_POLICY_PATH}" "${FALLBACK_POLICY_PATH}" "${DEFAULT_AUDIT_LINES}" <<'PY' +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def load_json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def safe_exists(path: Path) -> bool: + try: + return path.exists() + except PermissionError: + return False + + +def parse_optional_datetime(value): + if value in (None, ""): + return None + normalized = value[:-1] + "+00:00" if isinstance(value, str) and value.endswith("Z") else value + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def choose_policy(runtime_policy: Path, fallback_policy: Path): + if safe_exists(runtime_policy): + return runtime_policy, "runtime" + if safe_exists(fallback_policy): + return fallback_policy, "repo_fallback" + raise SystemExit("ERROR: no policy file available") + + +def load_policy(runtime_policy: Path, fallback_policy: Path): + policy_path, policy_source = choose_policy(runtime_policy, fallback_policy) + raw = load_json(policy_path) + broker = raw.get("broker", {}) + actions = raw.get("actions", {}) + telegram = raw.get("telegram", {}) + runtime_state_path = Path(str(broker.get("state_store_path", ""))) + audit_log_path = Path(str(broker.get("audit_log_path", ""))) + telegram_runtime_path = Path(str(telegram.get("runtime_status_path", ""))) + runtime_actions = {} + if safe_exists(runtime_state_path): + try: + payload = load_json(runtime_state_path) + runtime_actions = payload.get("actions", {}) + if not isinstance(runtime_actions, dict): + raise SystemExit("ERROR: runtime state actions must be an object") + except PermissionError: + runtime_actions = {} + return { + "policy_path": str(policy_path), + "policy_source": policy_source, + "actions": actions, + "runtime_actions": runtime_actions, + "runtime_state_path": runtime_state_path, + "audit_log_path": audit_log_path, + "telegram_runtime_path": telegram_runtime_path, + } + + +def summarize_states(config): + now = datetime.now(timezone.utc) + rows = [] + summary = {"enabled": 0, "disabled": 0, "expired": 0, "consumed": 0} + for action_id in sorted(config["actions"]): + declared = config["actions"][action_id] + override = config["runtime_actions"].get(action_id, {}) + enabled = bool(override.get("enabled", declared.get("enabled", False))) + mode = str(override.get("mode", declared.get("mode", "restricted"))) + expires_dt = parse_optional_datetime(override.get("expires_at")) if "expires_at" in override else parse_optional_datetime(declared.get("expires_at")) + one_shot = bool(override.get("one_shot", declared.get("one_shot", False))) + one_shot_consumed = bool(override.get("one_shot_consumed", False)) + reason = override.get("reason") if override.get("reason") is not None else declared.get("reason") + permission = str(declared.get("permission", "unset")) + expires_at = expires_dt.isoformat().replace("+00:00", "Z") if expires_dt else None + status = "enabled" + allowed = True + if not enabled: + status = "disabled" + allowed = False + elif expires_dt is not None and expires_dt <= now: + status = "expired" + allowed = False + elif one_shot and one_shot_consumed: + status = "consumed" + allowed = False + summary[status] = summary.get(status, 0) + 1 + suffix = [] + if one_shot: + suffix.append(f"one_shot={'yes' if one_shot else 'no'}") + suffix.append(f"consumed={'yes' if one_shot_consumed else 'no'}") + if expires_at: + suffix.append(f"expires_at={expires_at}") + if reason: + suffix.append(f"reason={reason}") + rows.append( + "{action_id} | status={status} | mode={mode} | allowed={allowed} | permission={permission}{suffix}".format( + action_id=action_id, + status=status, + mode=mode, + allowed="yes" if allowed else "no", + permission=permission, + suffix=f" | {' | '.join(suffix)}" if suffix else "", + ) + ) + return summary, rows + + +def runtime_summary(config): + print(f"policy_source={config['policy_source']}") + print(f"policy_path={config['policy_path']}") + print(f"runtime_state_path={config['runtime_state_path']}") + print(f"runtime_state_exists={'yes' if safe_exists(config['runtime_state_path']) else 'no'}") + print(f"audit_log_path={config['audit_log_path']}") + print(f"audit_log_exists={'yes' if safe_exists(config['audit_log_path']) else 'no'}") + print(f"telegram_runtime_path={config['telegram_runtime_path']}") + print(f"telegram_runtime_exists={'yes' if safe_exists(config['telegram_runtime_path']) else 'no'}") + + +def broker_state_console(config): + summary, rows = summarize_states(config) + print(f"policy_source={config['policy_source']}") + print(f"policy_path={config['policy_path']}") + print("scope=restricted_operator_capabilities") + print( + "summary total={total} enabled={enabled} disabled={disabled} expired={expired} consumed={consumed}".format( + total=len(rows), + enabled=summary.get("enabled", 0), + disabled=summary.get("disabled", 0), + expired=summary.get("expired", 0), + consumed=summary.get("consumed", 0), + ) + ) + print("legend readonly=view restricted=mutate allowed=can_execute now") + for row in rows: + print(row) + + +def broker_audit_recent(config, lines: int): + path = config["audit_log_path"] + if not safe_exists(path): + print("policy_source={}".format(config["policy_source"])) + print("scope=restricted_operator_audit lines=0") + print("no_events=yes") + return + raw_lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + print(f"policy_source={config['policy_source']}") + print(f"scope=restricted_operator_audit lines={len(raw_lines)}") + for line in raw_lines: + try: + event = json.loads(line) + except json.JSONDecodeError: + print("raw_event parse_error=yes") + continue + parts = [ + f"ts={event.get('ts', '?')}", + f"event={event.get('event', '?')}", + f"action_id={event.get('action_id', '-')}", + f"ok={event.get('ok')}", + ] + if event.get("operator_id"): + parts.append(f"operator_id={event.get('operator_id')}") + if event.get("operator_role"): + parts.append(f"operator_role={event.get('operator_role')}") + if event.get("code"): + parts.append(f"code={event.get('code')}") + if event.get("error"): + parts.append(f"error={event.get('error')}") + print(" | ".join(parts)) + + +def redact_payload(payload): + if isinstance(payload, dict): + redacted = {} + for key, value in payload.items(): + key_lower = str(key).lower() + if any(token in key_lower for token in ("token", "apikey", "api_key", "authorization", "secret")): + redacted[key] = "[REDACTED]" + else: + redacted[key] = redact_payload(value) + return redacted + if isinstance(payload, list): + return [redact_payload(item) for item in payload] + return payload + + +def telegram_runtime_status(config): + path = config["telegram_runtime_path"] + if not safe_exists(path): + raise SystemExit(f"ERROR: telegram runtime status not found: {path}") + payload = load_json(path) + print(json.dumps(redact_payload(payload), indent=2, sort_keys=True)) + + +def main(): + mode = sys.argv[1] + runtime_policy = Path(sys.argv[2]) + fallback_policy = Path(sys.argv[3]) + default_audit_lines = int(sys.argv[4]) + config = load_policy(runtime_policy, fallback_policy) + if mode == "runtime_summary": + runtime_summary(config) + return + if mode == "broker_state_console": + broker_state_console(config) + return + if mode == "broker_audit_recent": + broker_audit_recent(config, default_audit_lines) + return + if mode == "telegram_runtime_status": + telegram_runtime_status(config) + return + raise SystemExit(f"ERROR: unsupported mode {mode}") + + +main() +PY +} + +runtime_summary() { + print_header "runtime_summary" + run_python_helper "runtime_summary" +} + +broker_state_console() { + print_header "broker_state_console" + run_python_helper "broker_state_console" +} + +broker_audit_recent() { + print_header "broker_audit_recent" + run_python_helper "broker_audit_recent" +} + +telegram_runtime_status() { + print_header "telegram_runtime_status" + run_python_helper "telegram_runtime_status" +} + +operational_logs_recent() { + local unit output + local allowed_units=( + "openclaw-telegram-bot.service" + "inference-gateway.service" + "obsidian-vault-backup.service" + "obsidian-vault-restore-check.service" + "openclaw-boundary-backup.service" + ) + print_header "operational_logs_recent" + echo "lines_per_unit=${DEFAULT_UNIT_LOG_LINES}" + for unit in "${allowed_units[@]}"; do + echo "-- unit=${unit} --" + output="$("${JOURNALCTL_BIN}" -u "${unit}" -n "${DEFAULT_UNIT_LOG_LINES}" --no-pager --output=short-iso --quiet 2>/dev/null || true)" + if [[ -z "${output}" || "${output}" == "-- No entries --" ]]; then + echo "no_entries=yes" + else + printf '%s\n' "${output}" + fi + done +} + +main() { + require_root + require_deps + if [[ $# -ne 1 ]]; then + usage + fi + case "$1" in + runtime_summary) runtime_summary ;; + broker_state_console) broker_state_console ;; + broker_audit_recent) broker_audit_recent ;; + telegram_runtime_status) telegram_runtime_status ;; + operational_logs_recent) operational_logs_recent ;; + *) usage ;; + esac +} + +main "$@" diff --git a/templates/openclaw/davlos-openclaw-readonly.sudoers b/templates/openclaw/davlos-openclaw-readonly.sudoers new file mode 100644 index 0000000..0fc4d07 --- /dev/null +++ b/templates/openclaw/davlos-openclaw-readonly.sudoers @@ -0,0 +1,2 @@ +Cmnd_Alias DAVLOS_OPENCLAW_READONLY = /usr/local/sbin/davlos-openclaw-readonly runtime_summary, /usr/local/sbin/davlos-openclaw-readonly broker_state_console, /usr/local/sbin/davlos-openclaw-readonly broker_audit_recent, /usr/local/sbin/davlos-openclaw-readonly telegram_runtime_status, /usr/local/sbin/davlos-openclaw-readonly operational_logs_recent +devops ALL=(root) NOPASSWD: DAVLOS_OPENCLAW_READONLY diff --git a/templates/openclaw/telegram-bot.env.example b/templates/openclaw/telegram-bot.env.example index 8a8c765..2c0fc54 100644 --- a/templates/openclaw/telegram-bot.env.example +++ b/templates/openclaw/telegram-bot.env.example @@ -1,3 +1,8 @@ OPENCLAW_TELEGRAM_BOT_TOKEN=replace-with-real-private-bot-token # Optional: # TELEGRAM_BOT_LOG_LEVEL=INFO +# OPENCLAW_LLM_ENABLED=false +# OPENCLAW_LLM_PROVIDER=gemini +# OPENCLAW_LLM_MODEL=gemini-2.5-flash +# OPENCLAW_LLM_API_KEY=replace-with-real-llm-api-key +# OPENCLAW_LLM_TIMEOUT_SECONDS=8 diff --git a/tests/restricted_operator/test_broker.py b/tests/restricted_operator/test_broker.py index eb33a3f..469db14 100644 --- a/tests/restricted_operator/test_broker.py +++ b/tests/restricted_operator/test_broker.py @@ -2,6 +2,7 @@ import json import io +import os import sys import tempfile import threading @@ -21,6 +22,7 @@ from models import BrokerRequest # noqa: E402 from policy import PolicyStore # noqa: E402 from telegram_bot import TelegramCommandProcessor # noqa: E402 +from llm_adapter import LLMAdapter # noqa: E402 class _OkHandler(BaseHTTPRequestHandler): @@ -46,6 +48,29 @@ def send_message(self, *, chat_id: str, text: str) -> None: self.sent_messages.append((chat_id, text)) +class _FakeLLMAdapter: + def __init__(self, *, enabled: bool = True, response: dict | None = None, error: Exception | None = None) -> None: + self.enabled = enabled + self.response = response or { + "intent": "status", + "action_id": "telegram.command", + "params": {}, + "needs_confirmation": False, + "reply_style": "brief", + } + self.error = error + self.calls: list[str] = [] + + def is_enabled(self) -> bool: + return self.enabled + + def interpret(self, *, text: str) -> dict: + self.calls.append(text) + if self.error is not None: + raise self.error + return dict(self.response) + + class BrokerTests(unittest.TestCase): def setUp(self) -> None: self.tempdir = tempfile.TemporaryDirectory() @@ -271,6 +296,69 @@ def test_policy_store_can_reset_one_shot(self) -> None: self.assertIsNotNone(reset) self.assertEqual(reset.status, "enabled") + def test_policy_store_refreshes_runtime_state_for_long_lived_instances(self) -> None: + observer = PolicyStore(str(self.policy_path)) + writer = PolicyStore(str(self.policy_path)) + + writer.set_action_enabled( + "action.dropzone.write.v1", + enabled=False, + updated_by="tester", + reason="refresh_visibility", + ) + + effective = observer.get_effective_action_state("action.dropzone.write.v1") + self.assertIsNotNone(effective) + self.assertEqual(effective.status, "disabled") + + def test_concurrent_runtime_state_mutations_do_not_lose_updates(self) -> None: + first_store = PolicyStore(str(self.policy_path)) + second_store = PolicyStore(str(self.policy_path)) + start_barrier = threading.Barrier(2) + errors: list[Exception] = [] + + def disable_dropzone() -> None: + try: + start_barrier.wait() + first_store.set_action_enabled( + "action.dropzone.write.v1", + enabled=False, + updated_by="tester-a", + reason="concurrent_disable", + ) + except Exception as exc: # pragma: no cover - defensive path for thread failures + errors.append(exc) + + def enable_restart() -> None: + try: + start_barrier.wait() + second_store.set_action_enabled( + "action.openclaw.restart.v1", + enabled=True, + updated_by="tester-b", + reason="concurrent_enable", + ) + except Exception as exc: # pragma: no cover - defensive path for thread failures + errors.append(exc) + + threads = [ + threading.Thread(target=disable_dropzone), + threading.Thread(target=enable_restart), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(errors, []) + store = PolicyStore(str(self.policy_path)) + dropzone = store.get_effective_action_state("action.dropzone.write.v1") + restart = store.get_effective_action_state("action.openclaw.restart.v1") + self.assertIsNotNone(dropzone) + self.assertIsNotNone(restart) + self.assertEqual(dropzone.status, "disabled") + self.assertEqual(restart.status, "enabled") + def test_authorized_operator_can_change_policy(self) -> None: rc = broker_cli.set_enabled( str(self.policy_path), @@ -425,6 +513,231 @@ def test_telegram_conversational_capabilities_intent(self) -> None: self.assertIn("action.health.general.v1", reply) self.assertIn("exec=yes", reply) + def test_telegram_wake_starts_assistant_session(self) -> None: + reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + self.assertIn("Asistente despierto.", reply) + self.assertTrue(self.telegram._has_active_session(chat_id="1001", user_id="42", operator_id="authorized-operator")) + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("assistant_wake", audit_events) + + def test_telegram_sleep_ends_assistant_session(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="/sleep") + self.assertIn("Asistente dormido.", reply) + self.assertFalse(self.telegram._has_active_session(chat_id="1001", user_id="42", operator_id="authorized-operator")) + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("assistant_sleep", audit_events) + + def test_telegram_wake_timeout_expires_session(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + session_key = self.telegram._session_key(chat_id="1001", user_id="42") + self.telegram.assistant_idle_timeout_seconds = 60 + self.telegram.assistant_sessions[session_key].last_activity_at -= 120 + reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="haz algo inteligente con el sistema") + self.assertIn("No entendí la intención", reply) + self.assertFalse(self.telegram._has_active_session(chat_id="1001", user_id="42", operator_id="authorized-operator")) + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("assistant_sleep", audit_events) + + def test_telegram_awake_mode_accepts_natural_status_query(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="como estamos") + self.assertIn("Estado general de OpenClaw:", reply) + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("response_generated", audit_events) + + def test_telegram_awake_mode_accepts_capabilities_query_with_punctuation(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="que puedes hacer?") + self.assertIn("Capacidades activas y visibles para este operador:", reply) + + def test_telegram_awake_mode_accepts_identity_query(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="quien eres?") + self.assertIn("Soy el asistente controlado de OpenClaw", reply) + + def test_telegram_prudent_suggestion_is_conservative(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = self.telegram.handle_text( + chat_id="1001", + user_id="42", + text="que propones para mejorar la operacion sin tocar nada sensible?", + ) + self.assertIn("Propuesta prudente:", reply) + self.assertIn("auditoría", reply.lower()) + self.assertNotIn("restart", reply.lower()) + self.assertNotIn("habilitar ", reply.lower()) + self.assertNotIn("action.openclaw.restart.v1", reply.lower()) + + def test_telegram_conceptual_explain_status_is_not_generic_status_block(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = self.telegram.handle_text( + chat_id="1001", + user_id="42", + text="explicame brevemente que significa que haya una capacidad expirada y otra deshabilitada", + ) + self.assertIn("enabled", reply) + self.assertIn("disabled", reply) + self.assertIn("expired", reply) + self.assertIn("one-shot", reply) + self.assertNotIn("Estado general de OpenClaw:", reply) + + def test_telegram_awake_unknown_phrase_invokes_llm(self) -> None: + llm = _FakeLLMAdapter( + response={ + "intent": "status", + "action_id": "telegram.command", + "params": {}, + "needs_confirmation": False, + "reply_style": "brief", + } + ) + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = telegram.handle_text(chat_id="1001", user_id="42", text="dame una lectura rápida de cómo pinta todo hoy") + self.assertIn("Estado general de OpenClaw:", reply) + self.assertEqual(llm.calls, ["dame una lectura rápida de cómo pinta todo hoy"]) + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("llm_invoked", audit_events) + self.assertIn("llm_output_validated", audit_events) + + def test_telegram_known_prudent_suggestion_phrase_stays_local_first(self) -> None: + llm = _FakeLLMAdapter( + response={ + "intent": "enable_capability", + "action_id": "action.openclaw.restart.v1", + "params": {}, + "needs_confirmation": True, + "reply_style": "brief", + } + ) + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = telegram.handle_text( + chat_id="1001", + user_id="42", + text="que propones para mejorar la operacion sin tocar nada sensible?", + ) + self.assertIn("Propuesta prudente:", reply) + self.assertEqual(llm.calls, []) + + def test_telegram_sleep_mode_does_not_invoke_llm(self) -> None: + llm = _FakeLLMAdapter() + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + reply = telegram.handle_text(chat_id="1001", user_id="42", text="dame una lectura rápida de cómo pinta todo hoy") + self.assertIn("No entendí la intención", reply) + self.assertEqual(llm.calls, []) + + def test_telegram_slash_command_does_not_invoke_llm(self) -> None: + llm = _FakeLLMAdapter() + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + reply = telegram.handle_text(chat_id="1001", user_id="42", text="/status") + self.assertIn("actions_total=", reply) + self.assertEqual(llm.calls, []) + + def test_telegram_confirmation_does_not_invoke_llm(self) -> None: + llm = _FakeLLMAdapter() + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="1001", user_id="42", text="deshabilita action.dropzone.write.v1") + reply = telegram.handle_text(chat_id="1001", user_id="42", text="si") + self.assertIn("Acción aplicada.", reply) + self.assertEqual(llm.calls, []) + + def test_telegram_llm_invalid_output_falls_back_safely(self) -> None: + llm = _FakeLLMAdapter( + response={ + "intent": "status", + "action_id": "telegram.command", + "params": {}, + "needs_confirmation": False, + "reply_style": "brief", + "extra": "forbidden", + } + ) + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = telegram.handle_text(chat_id="1001", user_id="42", text="haz algo inteligente con el sistema") + self.assertIn("No puedo interpretar esa frase de forma segura.", reply) + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("llm_output_rejected", audit_events) + + def test_telegram_llm_rejects_intent_outside_allowlist(self) -> None: + llm = _FakeLLMAdapter( + response={ + "intent": "run_shell", + "action_id": "telegram.command", + "params": {}, + "needs_confirmation": False, + "reply_style": "brief", + } + ) + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = telegram.handle_text(chat_id="1001", user_id="42", text="haz algo inteligente con el sistema") + self.assertIn("No puedo interpretar esa frase de forma segura.", reply) + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("llm_output_rejected", audit_events) + + def test_telegram_llm_valid_read_still_obeys_auth(self) -> None: + llm = _FakeLLMAdapter( + response={ + "intent": "audit_tail", + "action_id": "telegram.command", + "params": {}, + "needs_confirmation": False, + "reply_style": "brief", + } + ) + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="2002", user_id="42", text="/wake") + reply = telegram.handle_text(chat_id="2002", user_id="42", text="cuéntame lo último importante") + self.assertEqual(reply, "Operador no autorizado para consultar auditoría.") + self.assertEqual(llm.calls, ["cuéntame lo último importante"]) + + def test_telegram_llm_valid_mutation_requires_confirmation(self) -> None: + llm = _FakeLLMAdapter( + response={ + "intent": "disable_capability", + "action_id": "action.dropzone.write.v1", + "params": {}, + "needs_confirmation": True, + "reply_style": "brief", + } + ) + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = telegram.handle_text(chat_id="1001", user_id="42", text="quita acceso de escritura al dropzone por ahora") + self.assertIn("Acción interpretada:", reply) + store = PolicyStore(str(self.policy_path)) + effective = store.get_effective_action_state("action.dropzone.write.v1") + self.assertIsNotNone(effective) + self.assertEqual(effective.status, "enabled") + + def test_telegram_disabled_llm_preserves_existing_fallback(self) -> None: + llm = _FakeLLMAdapter(enabled=False) + telegram = TelegramCommandProcessor(str(self.policy_path), api_client=self.telegram_client, llm_adapter=llm) + telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = telegram.handle_text(chat_id="1001", user_id="42", text="haz algo inteligente con el sistema") + self.assertIn("No puedo interpretar esa frase de forma segura.", reply) + self.assertEqual(llm.calls, []) + + def test_llm_adapter_ignores_invalid_timeout_when_disabled(self) -> None: + previous_enabled = os.environ.get("OPENCLAW_LLM_ENABLED") + previous_timeout = os.environ.get("OPENCLAW_LLM_TIMEOUT_SECONDS") + self.addCleanup(self._restore_env_var, "OPENCLAW_LLM_ENABLED", previous_enabled) + self.addCleanup(self._restore_env_var, "OPENCLAW_LLM_TIMEOUT_SECONDS", previous_timeout) + os.environ["OPENCLAW_LLM_ENABLED"] = "false" + os.environ["OPENCLAW_LLM_TIMEOUT_SECONDS"] = "invalid" + adapter = LLMAdapter() + self.assertFalse(adapter.is_enabled()) + + @staticmethod + def _restore_env_var(key: str, value: str | None) -> None: + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + def test_telegram_conversational_mutation_requires_confirmation(self) -> None: reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="deshabilita action.dropzone.write.v1") self.assertIn("Acción interpretada:", reply) @@ -447,6 +760,26 @@ def test_telegram_conversational_mutation_executes_after_confirmation(self) -> N self.assertIn("confirmation_accepted", audit_events) self.assertIn("action_executed", audit_events) + def test_telegram_awake_mutation_still_requires_confirmation(self) -> None: + self.telegram.handle_text(chat_id="1001", user_id="42", text="/wake") + reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="deshabilita action.dropzone.write.v1") + self.assertIn("Acción interpretada:", reply) + store = PolicyStore(str(self.policy_path)) + effective = store.get_effective_action_state("action.dropzone.write.v1") + self.assertIsNotNone(effective) + self.assertEqual(effective.status, "enabled") + + def test_telegram_awake_mutation_rejected_for_operator_without_permissions(self) -> None: + self.telegram.handle_text(chat_id="2002", user_id="42", text="/wake") + reply = self.telegram.handle_text(chat_id="2002", user_id="42", text="deshabilita action.dropzone.write.v1") + self.assertIn("No puedo proponer esa acción", reply) + store = PolicyStore(str(self.policy_path)) + effective = store.get_effective_action_state("action.dropzone.write.v1") + self.assertIsNotNone(effective) + self.assertEqual(effective.status, "enabled") + audit_events = [json.loads(line)["event"] for line in self.audit_path.read_text().strip().splitlines()] + self.assertIn("intent_rejected_unauthorized", audit_events) + def test_telegram_conversational_rejects_unsupported_intent(self) -> None: reply = self.telegram.handle_text(chat_id="1001", user_id="42", text="haz algo inteligente con el sistema") self.assertIn("No entendí la intención", reply)