#!/usr/bin/env python3 """bAgents CLI Runner — ejecuta en ESTE host los comandos que bAgents aprobó. Implementación de referencia (FR 57). El cliente lo instala en su PC/servidor. MODELO DE SEGURIDAD (lo que este programa NO hace, a propósito): - **No decide nada.** La política vive en bAgents: allow-list deny-by-default y aprobación humana obligatoria para lo destructivo. Este runner solo ejecuta lo que bAgents ya marcó como `approved`. Un comando pendiente de aprobación NUNCA le llega. - **No usa una shell.** Ejecuta con `subprocess.run(argv, shell=False)`. Por eso `ls; rm -rf /` no puede funcionar aunque alguien lo colara: no hay shell que interprete el `;`. (bAgents igual lo rechaza antes.) - **No corre como root.** Se instala bajo un usuario de SO de mínimo privilegio. Si te pide correrlo como root, algo está mal en tu diseño, no en el runner. - **No guarda credenciales de plataforma.** Su token (`rk_…`) es de ESTE host: si se filtra, no compromete a otros clientes ni a bAgents. SISTEMAS SOPORTADOS ------------------- El runner es Python puro (solo stdlib): corre en **Linux, macOS y Windows**. Lo que cambia por sistema es cómo se instala como servicio: Linux usuario de servicio dedicado + systemd (install.sh, con sudo) macOS LaunchAgent bajo TU usuario, nunca root (install.sh, SIN sudo) Windows tarea programada bajo tu usuario (ver README) INSTALACIÓN ----------- curl -fsSL https://bagents.buckler.pro/api/desktop/install.sh \ | BAGENTS_RUNNER_TOKEN=rk_... sudo -E bash # Linux curl -fsSL .../install.sh | BAGENTS_RUNNER_TOKEN=rk_... bash # macOS Ver `README.md` para la instalación manual y para Windows. """ from __future__ import annotations import os import pathlib import platform import shlex import subprocess import sys import time import urllib.error import urllib.request import json BAGENTS_URL = os.environ.get("BAGENTS_URL", "http://localhost:8000").rstrip("/") TOKEN = os.environ.get("BAGENTS_RUNNER_TOKEN", "") # Dónde se guarda el token obtenido por el flujo de conexión (RFC 8628). Se # escribe con permisos 600: es la credencial de ESTE host. TOKEN_FILE = pathlib.Path( os.environ.get("BAGENTS_TOKEN_FILE", str(pathlib.Path.home() / ".config" / "bagents" / "token")) ) POLL_SECONDS = int(os.environ.get("BAGENTS_POLL_SECONDS", "10")) # Un comando que se cuelga no debe colgar el runner para siempre. COMMAND_TIMEOUT = int(os.environ.get("BAGENTS_COMMAND_TIMEOUT", "300")) def _post(path: str, payload: dict | None = None) -> object: req = urllib.request.Request( f"{BAGENTS_URL}/api{path}", data=json.dumps(payload or {}).encode() if payload is not None else b"{}", headers={ "Content-Type": "application/json", "X-Runner-Token": TOKEN, # credencial de ESTE host (ver connect()) }, method="POST", ) with urllib.request.urlopen(req, timeout=30) as r: body = r.read() return json.loads(body) if body else None def execute(command: str, cwd: str) -> tuple[int, str]: """Ejecuta el comando SIN shell (shell=False). Esto es lo que hace estructuralmente imposible el encadenamiento: no hay intérprete que lea `;`, `&&` o `|`. bAgents ya los rechaza, pero la defensa se repite acá — el host no confía ciegamente en el servidor. `posix=False` en Windows: el modo POSIX trata el backslash como escape y destruye las rutas (`C:\\Users\\x` → `C:Usersx`). """ try: argv = shlex.split(command, posix=(os.name != "nt")) except ValueError as e: return 126, f"comando no parseable: {e}" if not argv: return 126, "comando vacío" try: p = subprocess.run( argv, shell=False, # ← NO shell. Es el punto. cwd=cwd, # ← la carpeta CONCEDIDA, no el home del servicio. capture_output=True, text=True, timeout=COMMAND_TIMEOUT, ) out = (p.stdout or "") + (("\n" + p.stderr) if p.stderr else "") return p.returncode, out.strip() except FileNotFoundError: return 127, f"ejecutable no encontrado: {argv[0]}" except subprocess.TimeoutExpired: return 124, f"timeout tras {COMMAND_TIMEOUT}s" except Exception as e: # noqa: BLE001 — se reporta, no se traga return 1, f"error de ejecución: {e}" def _post_public(path: str, payload: dict) -> tuple[int, dict]: """POST sin credencial (solo el flujo de conexión). Devuelve (status, body) sin lanzar en 202/4xx: el flujo los usa como señales.""" req = urllib.request.Request( f"{BAGENTS_URL}/api{path}", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read() return r.status, (json.loads(raw) if raw else {}) except urllib.error.HTTPError as e: raw = e.read() try: return e.code, json.loads(raw) if raw else {} except json.JSONDecodeError: return e.code, {} def load_token() -> str: """Token de este host: variable de entorno (instalación manual) o el archivo que dejó el flujo de conexión. Sin ninguno, hay que conectar.""" if TOKEN: return TOKEN try: return TOKEN_FILE.read_text().strip() except OSError: return "" def connect() -> str: """Device Authorization Flow (RFC 8628): pide un código, lo muestra para que un humano lo autorice en el portal, y espera el token. El token NUNCA se muestra en pantalla — llega por este canal y se guarda con permisos 600.""" status, data = _post_public("/desktop/device/start", { "hostname": platform.node(), "os": platform.system(), }) if status not in (200, 201): print(f"ERROR: no se pudo iniciar la conexión (HTTP {status}).", file=sys.stderr) return "" interval = int(data.get("interval", 5)) print() print(" Para conectar este equipo, abrí:") print(f" {data.get('verification_uri')}") print(f" e ingresá el código: {data.get('user_code')}") print() print(" Esperando autorización… (Ctrl-C para cancelar)") deadline = time.time() + int(data.get("expires_in", 600)) while time.time() < deadline: time.sleep(interval) status, body = _post_public("/desktop/device/poll", {"device_code": data["device_code"]}) if status == 202: continue # todavía nadie autorizó if status == 200 and body.get("token"): TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) TOKEN_FILE.write_text(body["token"]) TOKEN_FILE.chmod(0o600) print(f"✔ Equipo conectado. Credencial guardada en {TOKEN_FILE}") return body["token"] print(f"ERROR: {body.get('detail', f'HTTP {status}')}", file=sys.stderr) return "" print("ERROR: el código expiró sin autorización.", file=sys.stderr) return "" def connect_only() -> int: """Solo la conexión: muestra el código, espera la autorización y guarda el token. Lo usa el instalador ANTES de crear el servicio — así el código aparece en la terminal donde está la persona, no en el journal.""" return 0 if connect().startswith("rk_") else 1 def main() -> int: global TOKEN if "--connect" in sys.argv: return connect_only() TOKEN = load_token() if not TOKEN.startswith("rk_"): # Sin credencial: se conecta (el humano autoriza en el portal). Antes # esto era un error fatal que obligaba a pegar el token a mano. TOKEN = connect() if not TOKEN.startswith("rk_"): return 2 if os.name == "posix" and os.geteuid() == 0: # No es una sugerencia: correr como root anula el mínimo privilegio. print("ERROR: no ejecutes el runner como root. Usá un usuario de SO " "de mínimo privilegio (ej. 'bagents-runner').", file=sys.stderr) return 2 host = platform.node() print(f"bAgents runner activo en '{host}' → {BAGENTS_URL} (poll cada {POLL_SECONDS}s)") while True: try: jobs = _post(f"/cli-runner/poll?hostname={host}") or [] for job in jobs: cmd, cwd = job["command"], job["cwd"] if not os.path.isdir(cwd): # No se ejecuta en otro lado "para que algo pase": si la # carpeta concedida no existe en este host, eso ES el error. _post("/cli-runner/report", { "command_id": job["id"], "exit_code": 126, "output": f"la carpeta concedida no existe en este host: {cwd}", }) print(f" ← carpeta inexistente: {cwd}") continue print(f"→ ejecutando en {cwd}: {cmd}") code, output = execute(cmd, cwd) _post("/cli-runner/report", { "command_id": job["id"], "exit_code": code, "output": output, }) print(f" ← exit {code}") except urllib.error.HTTPError as e: if e.code == 401: print("ERROR: token inválido o revocado. El runner se detiene.", file=sys.stderr) return 1 print(f"aviso: HTTP {e.code} al consultar bAgents", file=sys.stderr) except Exception as e: # noqa: BLE001 — el runner no debe morir por un blip de red print(f"aviso: {e}", file=sys.stderr) time.sleep(POLL_SECONDS) if __name__ == "__main__": sys.exit(main())