JARVIS/nucleo/manos/sudo_root.py
sito 8e4bc8ad94 JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
2026-08-16 15:34:37 +02:00

134 lines
5.6 KiB
Python

"""Ejecutar ordenes como root de forma controlada.
El problema que resuelve. Newelle convierte `sudo` en `sudo -S` (leer la
contraseña de la entrada estandar), pero en el panel de voz no hay una entrada
donde teclearla, asi que se queda colgado esperando una contraseña que no
llega. Y las acciones marcadas para confirmar no tenian dialogo: solo decian
"confirme antes" y no ejecutaban nada.
Como se resuelve, y las decisiones de seguridad detras:
- La contraseña NUNCA se escribe en disco. Vive en memoria de este proceso solo
si el usuario activa el permiso de sesion, y se pasa a `sudo -S` por stdin.
- El permiso de sesion CADUCA: a los 15 minutos de la ultima orden se olvida
sola, y tambien al cerrar el panel. No es un "root para siempre".
- CADA orden con root muestra su comando exacto en un dialogo antes de correr.
El permiso de sesion solo ahorra re-teclear la contraseña, nunca salta el ver
y aprobar lo que se va a ejecutar. La voz no puede autoaprobar root.
Alternativa descartada: apoyarse solo en el timestamp de sudo (`sudo -v`). Es
mas limpio en teoria —lo gestiona el sistema— pero a traves de flatpak-spawn el
tty cambia entre llamadas y el cacheo no es fiable. Mantener la contraseña en
memoria del proceso, con caducidad y sin tocar disco, funciona siempre y el
usuario opta a ello de forma explicita.
"""
import re
import subprocess
import time
# A los 15 minutos sin usarla, la contraseña en memoria se olvida. Igual que el
# valor por defecto de sudo, para no sorprender.
CADUCIDAD = 15 * 60
# Detecta una invocacion real de sudo: al principio del comando o justo tras un
# separador (;, |, &, &&, ||, salto de linea), no la palabra "sudo" dicha como
# argumento ("echo sudo no cuenta"). Errar aqui solo pediria una contraseña de
# mas, no es peligroso, pero molesta.
_SUDO = re.compile(r"(?:^|[;&|\n]|&&|\|\|)\s*sudo(?:\s|$)")
def necesita_root(comando: str) -> bool:
"""True si el comando lanza algo con sudo."""
return bool(comando and _SUDO.search(comando))
class GestorRoot:
"""Guarda —o no— la contraseña de sesion, y ejecuta con ella.
Una instancia por panel. Al cerrar el panel se llama a olvida().
"""
def __init__(self, prefijo_host=None):
# prefijo para salir del sandbox; se pasa como argumento para poder
# probar esto sin flatpak
self._prefijo = list(prefijo_host or [])
self._password = None
self._ultimo_uso = 0.0
# -------------------------------------------------------------- sesion
def sesion_activa(self) -> bool:
"""Hay contraseña recordada y aun no ha caducado."""
if self._password is None:
return False
if time.monotonic() - self._ultimo_uso > CADUCIDAD:
self.olvida()
return False
return True
def olvida(self):
"""Borra la contraseña de memoria. Se llama al cerrar el panel y al
caducar. Se sobreescribe antes de soltarla, por si acaso."""
if self._password is not None:
self._password = "\x00" * len(self._password)
self._password = None
self._ultimo_uso = 0.0
def recuerda(self, password: str):
self._password = password
self._ultimo_uso = time.monotonic()
# ------------------------------------------------------------ ejecutar
def valida(self, password: str) -> bool:
"""Comprueba la contraseña con `sudo -S -v`, sin ejecutar nada mas.
-k borra cualquier timestamp previo para que la comprobacion sea de
verdad; -v solo actualiza credenciales. Si la contraseña es correcta
devuelve True; nunca lanza ningun comando del usuario.
"""
try:
r = subprocess.run(
self._prefijo + ["sudo", "-S", "-k", "-p", "", "-v"],
input=password + "\n", capture_output=True, text=True,
timeout=10)
return r.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def ejecuta(self, comando: str, password: str = None, timeout: int = 30):
"""Ejecuta el comando dando la contraseña a los sudo que lleve.
password: si se pasa, se usa esa vez; si no, la de la sesion. La
contraseña se pasa por stdin a `sudo -S`. No se registra en ningun
sitio.
Devuelve (salida, codigo). codigo -1 = no habia contraseña disponible.
"""
clave = password if password is not None else self._password
if clave is None:
return "No hay contraseña disponible para root.", -1
# que todos los sudo del comando lean de stdin sin imprimir el prompt
preparado = re.sub(r"(^|[;&|]|\s)sudo(\s)",
r"\1sudo -S -p '' \2", comando)
try:
r = subprocess.run(
self._prefijo + ["bash", "-lc", preparado],
input=clave + "\n", capture_output=True, text=True,
timeout=timeout)
salida = (r.stdout or r.stderr or "").strip()
# sudo escribe su prompt/errores en stderr aunque -p sea vacio;
# se limpia lo que es ruido de sudo, no del comando
salida = "\n".join(
l for l in salida.splitlines()
if l.strip() and not l.startswith(("[sudo]", "Sorry, try again")))
self._ultimo_uso = time.monotonic()
return salida, r.returncode
except subprocess.TimeoutExpired:
return "La orden con root ha tardado demasiado.", 124
except OSError as e:
return f"No se pudo ejecutar: {e}", 1
finally:
# no dejar la copia local viva mas de lo necesario
clave = "\x00" * len(clave)