Skip to content

Latest commit

 

History

History
606 lines (462 loc) · 29 KB

security_llm_optimization.md

File metadata and controls

606 lines (462 loc) · 29 KB

Revisión funcional y de optimización — security_llm

Fecha: 2026-05-15
Tipo: Crítica constructiva de funcionamiento, rendimiento y mantenibilidad
Nota: Los bugs de seguridad críticos ya están documentados en security_llm_review.md. Este documento se centra exclusivamente en lógica, arquitectura funcional, y oportunidades de mejora.


1. Arquitectura del agente (core/agent.py)

1.1 — _trim_context asume que messages[1] siempre existe

Problema:
_trim_context accede a self.messages[1] (la "misión") como invariante, pero chat_turn puede llamar a _trim_context sin haber añadido un segundo mensaje aún. Si el historial tiene un solo mensaje (el system prompt), self.messages[1] lanza IndexError.

# Actual — crash si len(messages) == 1
mission = self.messages[1]

Mejora:

system = self.messages[0]
has_mission = len(self.messages) > 1 and self.messages[1]["role"] == "user"
mission = self.messages[1] if has_mission else None

reserved = [system] + ([mission] if mission else [])
keep_recent = MAX_TOTAL_CONTEXT - len(reserved) - 1  # -1 para el summary
recent = self.messages[-keep_recent:]
dropped = len(self.messages) - len(reserved) - len(recent)
summary_msg = {"role": "assistant", "content": f"[Context trimmed: {dropped} messages removed.]"}
self.messages = reserved + [summary_msg] + recent

1.2 — Duplicación masiva entre run_mission y chat_turn

Problema:
Los bloques de ejecución de tools (resolución de función, _execute_with_scm, serialización del resultado, emisión de eventos, gestión de errores, deduplicación) están copiados palabra por palabra en ambos métodos. Son ~80 líneas idénticas duplicadas. Cualquier fix en uno no se propaga al otro (como se ve con BUG-11 de la primera review).

Mejora:
Extraer un método privado _execute_tool_call(tool_call) -> str que devuelva el result_str listo para añadir al historial, y que ambos métodos llamen:

async def _execute_tool_call(self, tool_call: dict, tools_called: set) -> tuple[str, bool]:
    """Returns (result_str, was_executed). False if duplicate."""
    func_name = tool_call["function"]["name"]
    args = tool_call["function"]["arguments"]
    call_key = self._make_call_key(func_name, args)
    if call_key in tools_called:
        return f"SKIP: {func_name} already called with these arguments.", False
    tools_called.add(call_key)
    bus.emit(EventType.AGENT_TOOL_CALL, {"tool": func_name, "args": args})
    try:
        func = self.tools_registry.get(func_name)
        if not func:
            raise ValueError(f"Tool {func_name} not found.")
        result = await anyio.to_thread.run_sync(
            lambda f=func, a=args, n=func_name: self._execute_with_scm(n, f, a)
        )
        result_val = self._unwrap_result(result)
        result_str = self._truncate_result(json.dumps(result_val, cls=DateTimeEncoder))
        bus.emit(EventType.AGENT_TOOL_RESULT, {"tool": func_name, "result": result_str})
        return result_str, True
    except Exception as e:
        error_msg = f"Error executing {func_name}: {e}"
        logger.error(error_msg)
        bus.emit(EventType.AGENT_TOOL_RESULT, {"tool": func_name, "error": error_msg})
        return error_msg, True

1.3 — Lógica de "no tools" en chat_turn es confusa y tiene condiciones solapadas

Problema:
La rama else (sin tool calls) de chat_turn tiene varias condiciones anidadas que se solapan y son difíciles de razonar: comprueba step > 0 AND consecutive_no_tool >= 1, pero también step == 0, y dentro del primer caso hace una subcomprobación de len(tools_called) == 0. El resultado es un comportamiento no intuitivo: si el agente no llama herramientas en el step 0, inyecta un nudge; si no llama en el step 1 pero ya había tools antes, rompe el loop. Pero si no hay tools y consecutive >= 1 y no hay tools llamadas, inyecta otro mensaje diferente. Con 3 rutas superpuestas es fácil introducir bucles infinitos.

Mejora:
Simplificar a una máquina de estados explícita:

# Pseudocódigo limpio
if tool_calls:
    consecutive_no_tool = 0
    await self._process_tool_calls(tool_calls, tools_called)
else:
    consecutive_no_tool += 1
    if "?" in content and step > 0:
        return content  # pregunta explícita → devolver control al usuario
    if consecutive_no_tool == 1 and step == 0:
        self._inject_directive("Execute the first technical step now.")
    elif consecutive_no_tool == 2 and not tools_called:
        self._inject_directive("No tools called yet. Execute immediately.")
    elif consecutive_no_tool >= 2:
        break  # salir al bloque de síntesis

1.4 — _call_ollama hace parsing de JSON con múltiples estrategias frágiles sin prioritización clara

Problema:
El fallback de parseo JSON en _call_ollama intenta primero json.loads directo, luego regex de bloques markdown, luego extracción fuzzy de {}. El problema es que en el bloque fuzzy, start puede sobreescribirse con un valor incorrecto si hay { y [ en el contenido, porque la lógica de priorización (qué viene antes, qué es más grande) está replicada en condicionales anidados difíciles de leer. Además, la variable first_match (ya documentada en la review de bugs) crea confusión al asignar start y luego sobreescribirlo.

Mejora:
Usar una función utilitaria ordenada en core/llm_utils.py:

def extract_json_blocks(text: str) -> list[Any]:
    """Extract all parseable JSON objects/arrays from text, in priority order."""
    candidates = []
    # 1. Whole string
    try: candidates.append(json.loads(text.strip()))
    except: pass
    # 2. Markdown fenced blocks
    for m in re.finditer(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL):
        try: candidates.append(json.loads(m.group(1)))
        except: pass
    # 3. Outermost balanced braces/brackets
    for opener, closer in [('{', '}'), ('[', ']')]:
        start = text.find(opener)
        end = text.rfind(closer)
        if start != -1 and end > start:
            try: candidates.append(json.loads(text[start:end+1]))
            except: pass
    return candidates

1.5 — El agente no tiene mecanismo de cancelación en run_mission

Problema:
run_mission corre como asyncio.Task en el bridge. Si el usuario quiere cancelar una misión en curso (p.ej., porque está atacando el target equivocado), no hay endpoint ni señal para interrumpirla. El único control es esperar a que llegue al max_steps o a que el LLM emita MISSION_COMPLETE.

Mejora:
Añadir un asyncio.Event de cancelación:

class PentestAgent:
    def __init__(self, ...):
        ...
        self._cancel_event = asyncio.Event()

    def request_cancel(self):
        self._cancel_event.set()

    async def run_mission(self, prompt, max_steps=40):
        self._cancel_event.clear()
        ...
        for step in range(max_steps):
            if self._cancel_event.is_set():
                bus.emit(EventType.AGENT_STATUS, {"status": "Cancelled"})
                self._save_state()
                return
            ...

Y en el bridge:

@app.post("/api/agent/cancel")
async def cancel_mission():
    agent = Container.resolve(PentestAgent)
    agent.request_cancel()
    return {"status": "cancel_requested"}

2. Rendimiento y concurrencia

2.1 — InventoryManager abre una conexión SQLite nueva en cada operación

Problema:
_get_conn() llama a sqlite3.connect(self.db_path) cada vez. Sin WAL mode ni pool, cada operación abre y cierra el fichero. Con el agente ejecutando 10-20 tools en un solo paso y todas intentando escribir al inventario, se producen OperationalError: database is locked en operaciones concurrentes.

def _get_conn(self):
    conn = sqlite3.connect(self.db_path)  # nueva conexión cada llamada, sin WAL
    conn.row_factory = sqlite3.Row
    return conn

Mejora:
Añadir WAL mode y timeout, e inicializar PRAGMA una sola vez:

def _get_conn(self) -> sqlite3.Connection:
    conn = sqlite3.connect(self.db_path, timeout=30, check_same_thread=False)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=NORMAL")
    return conn

Para cargas altas, considerar un threading.local() con una conexión por hilo, que es el patrón estándar para SQLite concurrente sin pool.

2.2 — InventoryManager no tiene índices en las columnas más consultadas

Problema:
Las tablas findings, hosts, y services no tienen índices. Filtrar findings por engagement_id o severity, o buscar hosts por ip, hace full table scan. Para engagements con cientos de hosts y miles de findings (p.ej., durante un scan masivo con Nuclei), las queries se vuelven lentas.

Mejora:
Añadir índices al _init_db:

conn.execute("CREATE INDEX IF NOT EXISTS idx_findings_engagement ON findings(engagement_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_hosts_engagement ON hosts(engagement_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_hosts_ip ON hosts(ip)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_services_host ON services(host_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_evidence_finding ON evidence(finding_id)")

2.3 — EventBus._history usa list.pop(0) — operación O(n)

Problema:
El historial de eventos se implementa con una lista Python y pop(0) para mantener el límite de 1000:

self._history.append(payload)
if len(self._history) > 1000:
    self._history.pop(0)   # O(n) — desplaza toda la lista

Con el agente emitiendo decenas de eventos por step (thoughts, tool calls, results), y bajo lock global de self._lock, esto bloquea brevemente todos los emisores.

Mejora:
Usar collections.deque(maxlen=1000):

from collections import deque

# En __init__:
cls._instance._history = deque(maxlen=1000)

# emit() — sin cambios: deque gestiona el límite automáticamente
self._history.append(payload)  # O(1), sin pop() manual

La iteración en get_recent() funciona igual sobre deque.

2.4 — JobManager tiene List sin importar y crea un thread por job sin límite

Problema 1:
list_jobs usa List[Dict[str, Any]] como tipo de retorno pero List no está importado de typing (solo Dict, Any, Optional, Callable). Produce NameError en Python < 3.9 si se hace from __future__ import annotations activo.

Problema 2:
create_job lanza threading.Thread(target=wrapper, daemon=True) sin ningún límite. Si el agente crea 50 jobs de larga duración (Nikto, Nmap full-range, Nuclei), el proceso tiene 50 threads bloqueados, más los del pool de anyio y del EventBus.

Mejora:

from typing import Dict, Any, Optional, Callable, List  # añadir List

# Usar ThreadPoolExecutor en lugar de un thread por job:
class JobManager:
    def __init__(self, max_workers: int = 8):
        self.jobs: Dict[str, Job] = {}
        self._lock = threading.Lock()
        self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="job")

    def create_job(self, tool: str, target_func: Callable, *args, **kwargs) -> str:
        job_id = str(uuid.uuid4())[:8]
        job = Job(id=job_id, tool=tool)
        with self._lock:
            self.jobs[job_id] = job
        self._executor.submit(self._run_job, job, target_func, *args, **kwargs)
        return job_id

    def _run_job(self, job: Job, func: Callable, *args, **kwargs):
        try:
            job.status = "running"
            job.result = func(*args, **kwargs)
            job.status = "completed"
        except Exception as e:
            job.status = "failed"
            job.error = str(e)
        finally:
            job.end_time = time.time()

2.5 — TaskQueue existe pero nunca se registra ni se arranca

Problema:
core/task_queue.py implementa una cola async con workers, retry y cancelación — exactamente lo que JobManager necesita para no spawnar threads ilimitados. Sin embargo, TaskQueue no se registra en el Container, no se arranca en ningún lifespan, y nadie la usa. Es código muerto completo.

Mejora:
Dos opciones: (a) eliminar TaskQueue y extender JobManager con ThreadPoolExecutor (más simple, ver 2.4), o (b) migrar JobManager para usar TaskQueue como backend (más correcto a largo plazo). Si se elige (b):

# En setup_container():
task_queue = TaskQueue(max_workers=8)
Container.register_singleton(TaskQueue, task_queue)

# En el lifespan del bridge (o en un hook de arranque del MCP server):
@asynccontextmanager
async def lifespan(app):
    setup_container(service_context="webapp")
    tq = Container.resolve(TaskQueue)
    await tq.start()
    yield
    await tq.stop()

3. Diseño del bridge (bridge/main.py)

3.1 — CORS wildcard (allow_origins=["*"]) en un servidor que gestiona aprobaciones HITL

Problema:
El bridge permite CORS desde cualquier origen. Los endpoints /api/approvals/approve y /api/approvals/deny son operaciones de alta consecuencia (aprobar exploits, credential dumps) que pueden ser llamadas por cualquier página web abierta en el navegador del operador (CSRF).

Mejora:
Restringir a los orígenes conocidos, o añadir un token CSRF:

ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8321").split(",")

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Content-Type", "X-CSRF-Token"],
)

3.2 — Los endpoints de aprobación no tienen autenticación alguna

Problema:
POST /api/approvals/approve acepta request_id y approver como query params libres. Cualquiera en la red que conozca un request_id puede aprobarse a sí mismo una operación CRITICAL. El approver puede ser cualquier string arbitrario.

Mejora:
Añadir un header de API key mínimo para el bridge en entornos no-localhost:

from fastapi import Header, HTTPException

BRIDGE_API_KEY = os.environ.get("BRIDGE_API_KEY", "")

def verify_api_key(x_api_key: str = Header(default="")):
    if BRIDGE_API_KEY and x_api_key != BRIDGE_API_KEY:
        raise HTTPException(status_code=403, detail="Invalid API key")

@app.post("/api/approvals/approve", dependencies=[Depends(verify_api_key)])
async def approve_request(request_id: str, approver: str = "operator:ui"):
    ...

3.3 — El WebSocket solo recibe mensajes pero no los procesa

Problema:
El endpoint /ws recibe texto del cliente pero lo ignora completamente (solo hace logger.info). Toda la comunicación es unidireccional: el bridge emite eventos al cliente pero el cliente no puede enviar comandos por WebSocket. Si se quiere enviar un mensaje al agente desde la UI por WS, actualmente es imposible — hay que ir por REST.

Mejora:
Añadir dispatch de comandos via WS para reducir latencia en el flujo interactivo:

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    try:
        while True:
            raw = await websocket.receive_text()
            try:
                msg = json.loads(raw)
                cmd = msg.get("command")
                if cmd == "chat":
                    asyncio.create_task(_handle_ws_chat(msg.get("message", ""), websocket))
                elif cmd == "cancel":
                    agent = Container.resolve(PentestAgent)
                    agent.request_cancel()
            except json.JSONDecodeError:
                pass
    except WebSocketDisconnect:
        manager.disconnect(websocket)

3.4 — _active_missions no expone estado al cliente

Problema:
El endpoint POST /api/agent/mission rechaza nuevas misiones si _active_missions no está vacío, pero no hay endpoint GET /api/agent/mission/status que permita al cliente saber si hay una misión activa, cuántos steps lleva, o cuál es el estado actual. El cliente solo recibe eventos por WS, y si se reconecta pierde el contexto.

Mejora:

@app.get("/api/agent/status")
async def agent_status():
    return {
        "active": bool(_active_missions),
        "count": len(_active_missions),
        "recent_events": bus.get_recent(EventType.AGENT_STATUS, limit=5)
    }

4. Duplicación de metadatos de tools

4.1 — TOOL_METADATA en main.py y _AGENT_TOOL_METADATA en agent.py están desincronizados

Problema:
Los perfiles y niveles de riesgo de cada tool están definidos en dos diccionarios separados. TOOL_METADATA en main.py tiene la clasificación correcta (p.ej., sqlmap_scan = EXPLOIT/L3); _AGENT_TOOL_METADATA en agent.py tiene la clasificación incorrecta (p.ej., sqlmap_scan = READ/L0, kali_exec = READ/L0). Cuando el agente ejecuta tools directamente, aplica la clasificación equivocada. Toda corrección en uno hay que recordar aplicarla en el otro.

Mejora:
Centralizar en un único módulo core/tool_registry.py e importar desde ambos puntos:

# core/tool_registry.py
TOOL_RISK_METADATA: dict[str, dict] = {
    "port_scan":    {"profile": "PROBE",   "level": "L2"},
    "nikto_scan":   {"profile": "SCAN",    "level": "L3"},
    "kali_exec":    {"profile": "SCAN",    "level": "L2"},
    "sqlmap_scan":  {"profile": "EXPLOIT", "level": "L3"},
    "hook_function":{"profile": "EXPLOIT", "level": "L3"},
    ...
}
# En main.py y agent.py:
from core.tool_registry import TOOL_RISK_METADATA

5. SessionTools — clase sin encabezado de clase

Problema:
tools/sessions.py tiene def __init__ y todos los métodos de SessionTools pero le falta la declaración class SessionTools:. El archivo arranca directamente en el __init__ como código suelto. Python lo parsea pero lanza SyntaxError o IndentationError dependiendo del contexto de importación. Si actualmente funciona, es porque register_all_tools lo importa dentro de una función, no a nivel de módulo — pero es una bomba de tiempo.

Corrección:

# Añadir antes del __init__:
class SessionTools:
    def __init__(self, scm: SecurityControlModel, audit: AuditTrail, 
                 approval: ApprovalManager, workspace_manager: WorkspaceManager):
        ...

6. MissionPlan — race condition en persistencia YAML

Problema:
plan_init y plan_update llaman a self.plan.save_to_file("state/mission_plan.yaml") directamente desde el thread del agente. Si el agente autónomo actualiza el plan varias veces en rápida sucesión (varios plan_update en el mismo step), pueden ocurrir escrituras concurrentes al mismo fichero YAML sin ningún lock, corrompiendo el fichero.

Mejora:
Añadir un lock a nivel de MissionPlan:

class MissionPlan:
    def __init__(self, ...):
        ...
        self._file_lock = threading.Lock()

    def save_to_file(self, path: str):
        with self._file_lock:
            with open(path, "w") as f:
                yaml.dump(self.model_dump(mode="json"), f)

7. llm_utils.py — funciones duplicadas con OllamaClient en agent.py

Problema:
core/llm_utils.py define call_ollama() con los mismos parámetros (modelo, temperatura, num_ctx, etc.) que OllamaClient.chat() en agent.py. ReversingTools usa OllamaClient directamente (instanciado en tools/__init__.py). Hay tres formas diferentes de llamar a Ollama en el codebase, ninguna compartiendo configuración. Si se cambia el modelo base, hay que actualizar tres sitios.

Mejora:
Consolidar en llm_utils.py con un cliente compartido:

# core/llm_utils.py — único cliente Ollama para todo el sistema
class OllamaClient:
    def __init__(self, base_url: str = None, model: str = None):
        cfg = Container.resolve(dict)  # config.yaml
        ollama_cfg = cfg.get("ollama", {})
        self.base_url = base_url or ollama_cfg.get("url", "http://localhost:11435")
        self.model = model or ollama_cfg.get("model", "qwen2.5-coder:7b-openclaw")
        self._options = {
            "temperature": ollama_cfg.get("temperature", 0.4),
            "num_ctx": ollama_cfg.get("num_ctx", 24576),
            "num_predict": ollama_cfg.get("num_predict", 4096),
            "repeat_penalty": ollama_cfg.get("repeat_penalty", 1.1),
            "top_p": ollama_cfg.get("top_p", 0.9),
        }

Y todos los demás módulos importan from core.llm_utils import OllamaClient.


8. Menores

8.1 — CircuitBreaker solo está en GhidraServerAdapter

El CircuitBreaker es una utilidad general pero solo se aplica al adapter de Ghidra. Adapters como Nmap, Nikto, Nuclei o Metasploit también pueden colgarse, timeout, o entrar en estados de error repetidos — precisamente los casos que el circuit breaker resuelve. Sería valioso aplicarlo en GenericDockerAdapter.execute() con un breaker por herramienta.

8.2 — mcp_console.py carga config.yaml duplicando la lógica de dependencies.py

La consola lee config.yaml directamente para obtener el puerto del bridge, replicando el mismo bloque try/except que EventBus.__new__ y dependencies.py. Si se añade un campo nuevo a la config, hay que actualizarlo en tres sitios. Extraer una función load_config() en core/dependencies.py y exportarla para todos los consumidores (ya existe como función local, solo falta hacerla pública).

8.3 — AUTO_APPROVE_CRITICAL en producción: el hilo de auto-aprobación no loggea el contexto completo

El hilo de auto-aprobación (_approval_cli) aprueba silenciosamente todo lo que hay en cola bajo approver="admin-auto". Si este env var queda activo accidentalmente en producción (variable de entorno residual en CI/CD), no hay warning prominente en el arranque. Añadir:

if os.environ.get("AUTO_APPROVE_CRITICAL") == "1":
    logger.warning("⚠️  AUTO_APPROVE_CRITICAL is ENABLED — all CRITICAL actions will be auto-approved. "
                   "DO NOT use in production environments.")

8.4 — bridge/main.py no tiene lifespan cleanup del agente

Si el bridge se reinicia mientras hay una misión activa, el agente pierde estado de _active_missions y los events que estaba emitiendo. El lifespan actual solo llama setup_container() al arranque sin ninguna limpieza al parar. Añadir:

@asynccontextmanager
async def lifespan(app: FastAPI):
    setup_container(service_context="webapp")
    yield
    # Cleanup
    for task in list(_active_missions):
        task.cancel()
    await asyncio.gather(*_active_missions, return_exceptions=True)
    bus.shutdown()

Resumen de prioridades

Prioridad Área Item
🔴 Alta agent.py Duplicación run_mission/chat_turn — riesgo de divergencia
🔴 Alta sessions.py Falta class SessionTools: — SyntaxError latente
🔴 Alta tool_registry Doble TOOL_METADATA desincronizado — SCM bypass silencioso
🟠 Media agent.py _trim_context crash si messages < 2
🟠 Media agent.py Sin mecanismo de cancelación de misión
🟠 Media bridge CORS wildcard + endpoints de aprobación sin auth
🟠 Media inventory Sin WAL mode ni índices — locks en cargas altas
🟠 Media jobs Thread ilimitado por job + List no importado
🟡 Baja events pop(0) O(n) → deque
🟡 Baja task_queue TaskQueue nunca arrancada — código muerto
🟡 Baja planning Race condition en save YAML
🟡 Baja llm_utils Tres clientes Ollama duplicados
🟡 Baja circuit_breaker Solo en GhidraAdapter — debería aplicarse en GenericDocker
🟡 Baja bridge WS solo recibe, no procesa comandos
🟡 Baja bridge Sin estado de misión activa accesible por API

9. Formatos de Prompts y Tokens Especiales (qwen2.5-coder:7b-openclaw)

Durante una auditoría de tokens especiales generados y consumidos por el modelo a través de Ollama, se identificaron y resolvieron desajustes entre la plantilla de Go (template) utilizada por el modelo y el código:

9.1 — Doble envoltura de <tool_response> (agent_context.py)

Problema: El SDK de Ollama y su template por defecto ya envuelven automáticamente el contenido de los mensajes con role="tool" dentro de las etiquetas <tool_response>...</tool_response> y asignan el rol <|im_start|>user. En agent_context.py, el código añadía manualmente estas etiquetas al texto. Esto generaba un doble anidamiento (<tool_response><tool_response>...</tool_response></tool_response>), lo cual degradaba la comprensión del modelo y desperdiciaba tokens.

Mejora: Se eliminó la envoltura manual en agent_context.py. Ahora se deja que la plantilla de Ollama gestione los tags cuando el rol es "tool".

9.2 — Instrucciones duplicadas de formato de Tools (prompt_builder.py)

Problema: La plantilla de Ollama ya inyecta automáticamente un bloque # Tools y explica explícitamente cómo debe formatearse un <tool_call> (en formato JSON) en el system prompt cuando se pasan herramientas. Sin embargo, prompt_builder.py inyectaba nuevamente un bloque extenso con instrucciones de formato de llamadas (TOOL CALL FORMAT), causando redundancia de ~100 tokens y ambigüedad potencial.

Mejora: Se limpió el bloque de prompt_builder.py para dejar únicamente las directivas de comportamiento (WAIT_FOR_JOBS, job_status, MISSION_COMPLETE) en la sección TOOL CALL BEHAVIORS, eliminando las instrucciones estructurales redundantes.

9.3 — Desalineación de Temperatura (llm_adapter.py)

Problema: El blob del modelo qwen2.5-coder:7b-openclaw tiene una temperatura por defecto de 0.3 (optimizada para resultados analíticos y herramientas). llm_adapter.py y OllamaClient forzaban la temperatura a 0.4, incrementando la creatividad por encima de lo deseado para un agente de pentesting determinista.

Mejora: Se alineó la temperatura hardcodeada a 0.3 en llm_adapter.py para coincidir con la recomendación intrínseca del creador del modelo.


10. Gestión de Tareas en Background y Whitelist de Seguridad (Phase 2)

Durante la optimización funcional del flujo interactivo de pentesting, se auditaron y corrigieron problemas graves relacionados con la ejecución en background y la limitación de comandos de los adapters:

10.1 — Bucle de Consulta Infinito en Capturas de Red (core/tool_registry.py & tools/network_analysis.py)

Problema: El comando live_packet_analysis realiza su propia gestión de hilos y contenedores en segundo plano y transmite datos de forma nativa a través de eventos LIVE_PACKET_BATCH. Sin embargo, estaba registrado en el set de ASYNC_TOOLS de core/tool_registry.py. Esto causaba que agent.py envolviera el comando en una tarea del JobManager (ID 52eb6e1a), declarando el inicio exitoso inmediatamente como "completado". El LLM interpretaba esto como una tarea terminada e intentaba reiteradamente consultar su estado mediante job_status usando el ID interno sniff-XXXX (no registrado en el gestor global), cayendo en un bucle infinito de reintentos y errores.

Mejora:

  1. Se eliminó live_packet_analysis de ASYNC_TOOLS para permitir que el bridge y el agente lo traten como un proceso de streaming directo.
  2. Se modificó el mensaje de éxito retornado por la función en tools/network_analysis.py para inyectar una directiva clara de comportamiento en el payload: "Captured packets will automatically stream into your chat context as LIVE_PACKET_BATCH events. Do NOT use the 'job_status' tool to poll this job...".

10.2 — Caídas por Denegación de Comandos Básicos en Adapters (config.yaml)

Problema: El GenericDockerAdapter restringe la ejecución a una lista de comandos permitidos (whitelist) dentro de config.yaml. Sin embargo, múltiples scripts especializados (como webapp.py, recon_active.py, vuln_analysis.py) ejecutan comandos de apoyo y utilidades de shell/forenses de forma legítima (p.ej. cat, bash, python3, rabin2, radiff2) mediante self.generic.execute(). La ausencia de estas utilidades en la whitelist causaba excepciones inmediatas AdapterError bloqueando la ejecución completa del agente.

Mejora: Se añadieron de manera segura las utilidades Linux necesarias (cat, bash, python3, rabin2, radiff2) a la lista de permitidos en la configuración del adapter genérico en config.yaml, evitando caídas críticas durante el análisis dinámico.

10.3 — Inyección de Reglas de Metacognición y Debate (core/prompt_builder.py)

Problema: El modelo no siempre consultaba el plan de la misión (plan_update) ni debatía con antelación el encadenamiento de exploits antes de lanzarse a la ejecución de comandos intrusivos de red, ignorando a menudo la evaluación de si sus objetivos intermedios ya se encontraban completados.

Mejora: Se optimizaron las reglas estructurales de los system prompts (tanto en interactive como en autonomous de prompt_builder.py) agregando la obligación de invocar sequentialthinking o plan_update para debatir estratégicamente las fases del ataque y comprobar la compleción de objetivos antes de proseguir a una nueva fase.