diff --git a/BOOT.md b/BOOT.md index 4581b95..386ed0f 100644 --- a/BOOT.md +++ b/BOOT.md @@ -1,58 +1,80 @@ -# NeuroRift + OpenClaw Boot Sequence +# NeuroRift + OpenClaw Boot Sequence (Stabilized) -This runbook initializes NeuroRift as a primary OpenClaw agent and loads proactive monitoring. +This runbook initializes NeuroRift as a primary OpenClaw agent with strict sandboxing, approval controls, heartbeat discipline, and deterministic Docker runtime. -## 1) Preflight +## 1) Preflight (mandatory) -1. Export provider and channel secrets: - - `OPENAI_API_KEY` - - `ANTHROPIC_API_KEY` or `CLAUDE_API_KEY` - - `ZAI_API_KEY` or `Z_AI_API_KEY` - - `OPENCLAW_DISCORD_WEBHOOK_URL` (optional) - - `OPENCLAW_TELEGRAM_BOT_TOKEN` + `OPENCLAW_TELEGRAM_CHAT_ID` (optional) -2. Confirm services: - - NeuroRift FastAPI bridge on `:8766` - - OpenClaw WebSocket gateway on `:18789` +Export required runtime env: +- `OPENCLAW_CONFIG_PATH` +- `OPENCLAW_STATE_DIR` +- `OLLAMA_HOST` +- `NEURORIFT_BRIDGE_URL` -## 2) Start NeuroRift FastAPI bridge +Export provider/channel secrets as needed: +- `OPENAI_API_KEY` +- `ANTHROPIC_API_KEY` or `CLAUDE_API_KEY` +- `ZAI_API_KEY` or `Z_AI_API_KEY` +- `OPENCLAW_DISCORD_WEBHOOK_URL` (optional) +- `OPENCLAW_TELEGRAM_BOT_TOKEN` + `OPENCLAW_TELEGRAM_CHAT_ID` (optional) + +Run cross-device doctor checks: ```bash -python3 modules/web/bridge_server.py +python3 scripts/openclaw_doctor.py ``` -Health check: +## 2) Start deterministic Docker runtime ```bash -curl -s http://127.0.0.1:8766/health +docker compose up -d --build gateway neurorift-core rust-engine web-ui ollama sandbox-runner ``` -## 3) Start OpenClaw gateway +Verify service health: + +```bash +docker compose ps +``` -Use your OpenClaw runtime with the unified config: +## 3) Start NeuroRift FastAPI bridge ```bash -openclaw gateway --config ./openclaw.json5 +python3 modules/web/bridge_server.py ``` -## 4) Start the adapter bridge +Health check: ```bash +curl -s http://127.0.0.1:8766/health +``` + +## 4) Start OpenClaw gateway and adapter + +```bash +openclaw gateway --config ./openclaw.json5 python3 integrations/openclaw/openclaw_gateway_adapter.py ``` -The adapter maps NeuroRift internal calls into OpenClaw RPC methods: -- `run_terminal_cmd -> exec` -- `read_file -> read` -- `write_file -> write` -- `process_state -> process` +Adapter policy guarantees: +- Terminal-only execution path for operator actions. +- Sandbox workdir enforced at `/workspace`. +- Tool allow/deny lists enforced. +- High-risk operations forwarded for Discord/Telegram approval, timeout auto-deny. -## 5) Load HEARTBEAT checklist +## 5) HEARTBEAT discipline -Review and execute `HEARTBEAT.md` at startup and once every shift. +Read `HEARTBEAT.md` at startup and every configured interval. +- No action needed → emit `HEARTBEAT_OK` only. +- Action needed → generate structured task and log decision. ## 6) Validate end-to-end flow 1. Send a recon request from Discord/Telegram/WhatsApp/Signal. -2. Verify the request opens an `isolated` session. -3. Confirm high-risk commands trigger approval forwarder messages. -4. Confirm scheduled job appears in CronService (`weekly-attack-surface-recon`). +2. Verify request is processed with isolated session identity. +3. Confirm response returns to originating channel with mention/group policy preserved. +4. Confirm high-risk commands trigger approval forwarder events. +5. Confirm CronService scheduling guard prevents same-second loops. +6. Follow diagnostic events from control stream with: + +```bash +openclaw logs.follow +``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e4ea9e7..d8f0e7a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,56 +1,45 @@ -# ────────────────────────────────────────────────────────────────────────────── -# NeuroRift × OpenClaw — Development Override -# -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -# -# Changes vs production: -# - Source code is volume-mounted for hot reload -# - Python bridge uses uvicorn --reload -# - Next.js runs in dev mode (npm run dev) -# - Extra ports exposed for debugging -# ────────────────────────────────────────────────────────────────────────────── - name: neurorift services: - - neurorift: + neurorift-core: build: - target: base # stop at base stage, skip entrypoint switch + target: base volumes: - # Mount source directly for live reloads - ./modules:/app/modules - ./utils:/app/utils - ./prompts:/app/prompts - ./configs:/app/configs - ./ai_wrapper:/app/ai_wrapper environment: - LOG_LEVEL: "debug" + LOG_LEVEL: debug ports: - - "127.0.0.1:8766:8766" # expose for local debugging + - "127.0.0.1:8766:8766" command: > uvicorn modules.web.bridge_server:app --host 0.0.0.0 --port 8766 --reload --log-level debug - openclaw: + rust-engine: ports: - - "127.0.0.1:8765:8765" # expose for local debugging + - "127.0.0.1:8765:8765" environment: - RUST_LOG: "debug" + RUST_LOG: debug + + gateway: + ports: + - "127.0.0.1:18789:18789" web-ui: build: - target: builder # use builder stage with full dev tooling + target: builder volumes: - ./web-ui:/app - - /app/node_modules # anonymous volume to prevent host override - - /app/.next # prevent stale build cache from host + - /app/node_modules + - /app/.next ports: - "3000:3000" environment: - NODE_ENV: "development" + NODE_ENV: development command: npm run dev ollama: ports: - - "127.0.0.1:11434:11434" # expose Ollama locally in dev for direct testing + - "127.0.0.1:11434:11434" diff --git a/docker-compose.yml b/docker-compose.yml index 17aeaac..15da0dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,91 +1,39 @@ -# ────────────────────────────────────────────────────────────────────────────── -# NeuroRift × OpenClaw — Docker Compose -# -# Services: -# ollama — AI model server (internal port 11434) -# neurorift — Python bridge (internal port 8766) -# openclaw — Rust WS core (internal port 8765) -# web-ui — Next.js UI (host port 3000) -# -# Quick Start: -# docker compose up --build -# docker compose exec ollama ollama pull llama3 -# -# ────────────────────────────────────────────────────────────────────────────── - name: neurorift -# ─── Named Volumes ──────────────────────────────────────────────────────────── volumes: ollama_models: - # Ollama model blobs — persists across restarts - driver: local neurorift_sessions: - # Session state (JSON) - driver: local neurorift_audit: - # Audit log files - driver: local neurorift_evolution: - # Evolution / mutation data - driver: local + gateway_state: -# ─── Networks ───────────────────────────────────────────────────────────────── networks: neurorift_net: driver: bridge - internal: false # allows outbound internet for tool downloads / Ollama pulls -# ─── Services ───────────────────────────────────────────────────────────────── services: - - # ── Ollama AI model server ────────────────────────────────────────────────── ollama: image: ollama/ollama:latest - container_name: neurorift_ollama restart: unless-stopped - networks: - - neurorift_net + networks: [neurorift_net] volumes: - ollama_models:/root/.ollama - ports: - # NOT exposed to host by default — internal only. - # Uncomment the line below ONLY if you need direct host access for debugging. - # - "127.0.0.1:11434:11434" - expose: - - "11434" - environment: - OLLAMA_HOST: "0.0.0.0" + expose: ["11434"] healthcheck: - test: [ "CMD", "curl", "-sf", "http://localhost:11434/api/tags" ] + test: ["CMD", "curl", "-sf", "http://localhost:11434/api/tags"] interval: 15s timeout: 10s - start_period: 30s retries: 5 - # ── Optional GPU support (NVIDIA) ───────────────────────────────────────── - # To enable GPU, uncomment the `deploy` block below and ensure the - # NVIDIA Container Toolkit is installed on your host: - # https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html - # deploy: - # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] + start_period: 30s - # ── NeuroRift Python Bridge ───────────────────────────────────────────────── - neurorift: + neurorift-core: build: - context: . # Build context is repo root + context: . dockerfile: docker/neurorift/Dockerfile image: neurorift/bridge:latest - container_name: neurorift_bridge restart: unless-stopped - networks: - - neurorift_net - expose: - - "8766" + networks: [neurorift_net] + expose: ["8766"] volumes: - neurorift_sessions:/data/neurorift/sessions - neurorift_audit:/data/neurorift/audit @@ -96,10 +44,6 @@ services: BRIDGE_PORT: "8766" NEURORIFT_HOME: "/data/neurorift" LOG_LEVEL: "${LOG_LEVEL:-info}" - OLLAMA_MAIN_MODEL: "${OLLAMA_MAIN_MODEL:-llama3}" - OLLAMA_ASSISTANT_MODEL: "${OLLAMA_ASSISTANT_MODEL:-llama3}" - AI_ENABLED: "${AI_ENABLED:-true}" - # API keys — leave blank to use Ollama only OPENAI_API_KEY: "${OPENAI_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" GOOGLE_API_KEY: "${GOOGLE_API_KEY:-}" @@ -107,69 +51,99 @@ services: ollama: condition: service_healthy healthcheck: - test: [ "CMD", "curl", "-sf", "http://localhost:8766/health" ] + test: ["CMD", "curl", "-sf", "http://localhost:8766/health"] interval: 10s timeout: 5s - start_period: 40s retries: 5 + start_period: 40s - # ── OpenClaw (NeuroRift Rust Core) ────────────────────────────────────────── - openclaw: + rust-engine: build: context: . dockerfile: docker/openclaw/Dockerfile image: neurorift/openclaw:latest - container_name: neurorift_openclaw restart: unless-stopped - networks: - - neurorift_net - expose: - - "8765" - volumes: - - neurorift_sessions:/data/neurorift/sessions + networks: [neurorift_net] + expose: ["8765"] environment: NEURORIFT_HOME: "/data/neurorift" NEURORIFT_WS_ADDR: "0.0.0.0:8765" - NEURORIFT_BRIDGE_URL: "http://neurorift:8766" + NEURORIFT_BRIDGE_URL: "http://neurorift-core:8766" RUST_LOG: "${RUST_LOG:-info}" + volumes: + - neurorift_sessions:/data/neurorift/sessions depends_on: - neurorift: + neurorift-core: condition: service_healthy healthcheck: - # TCP-level check: try to open port 8765 - test: [ "CMD-SHELL", "curl -sf http://localhost:8765/health 2>/dev/null || (echo >/dev/tcp/localhost/8765) 2>/dev/null || exit 1" ] + test: ["CMD-SHELL", "curl -sf http://localhost:8765/health 2>/dev/null || (echo >/dev/tcp/localhost/8765) 2>/dev/null || exit 1"] interval: 10s timeout: 5s + retries: 5 start_period: 45s + + gateway: + image: neurorift/openclaw:latest + restart: unless-stopped + networks: [neurorift_net] + expose: ["18789"] + volumes: + - gateway_state:/data/openclaw + - neurorift_sessions:/data/neurorift/sessions + environment: + OPENCLAW_CONFIG_PATH: /workspace/NeuroRift/openclaw.json5 + OPENCLAW_STATE_DIR: /data/openclaw + OPENCLAW_GATEWAY_ADDR: "0.0.0.0:18789" + NEURORIFT_BRIDGE_URL: "http://neurorift-core:8766" + OLLAMA_HOST: "http://ollama:11434" + depends_on: + neurorift-core: + condition: service_healthy + rust-engine: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "echo >/dev/tcp/localhost/18789"] + interval: 10s + timeout: 5s retries: 5 + start_period: 30s + + sandbox-runner: + image: openclaw-sandbox:bookworm-slim + restart: unless-stopped + networks: [neurorift_net] + command: ["sleep", "infinity"] + healthcheck: + test: ["CMD", "sh", "-c", "echo sandbox-ready"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s - # ── Web UI (Next.js) ───────────────────────────────────────────────────────── web-ui: build: context: . dockerfile: docker/web-ui/Dockerfile args: - NEURORIFT_BRIDGE_URL: "http://neurorift:8766" - OPENCLAW_WS_URL: "http://openclaw:8765" + NEURORIFT_BRIDGE_URL: "http://neurorift-core:8766" + OPENCLAW_WS_URL: "http://gateway:18789" image: neurorift/web-ui:latest - container_name: neurorift_webui restart: unless-stopped - networks: - - neurorift_net + networks: [neurorift_net] ports: - - "${WEB_UI_PORT:-3000}:3000" # Only port exposed to host + - "${WEB_UI_PORT:-3000}:3000" environment: - NODE_ENV: "production" - NEURORIFT_BRIDGE_URL: "http://neurorift:8766" - OPENCLAW_WS_URL: "http://openclaw:8765" + NODE_ENV: production + NEURORIFT_BRIDGE_URL: "http://neurorift-core:8766" + OPENCLAW_WS_URL: "http://gateway:18789" PORT: "3000" HOSTNAME: "0.0.0.0" depends_on: - openclaw: + gateway: condition: service_healthy healthcheck: - test: [ "CMD", "wget", "-qO-", "http://localhost:3000" ] + test: ["CMD", "wget", "-qO-", "http://localhost:3000"] interval: 15s timeout: 10s - start_period: 30s retries: 5 + start_period: 30s diff --git a/docs/OPENCLAW_INTEGRATION_AUDIT.md b/docs/OPENCLAW_INTEGRATION_AUDIT.md new file mode 100644 index 0000000..d1df9f7 --- /dev/null +++ b/docs/OPENCLAW_INTEGRATION_AUDIT.md @@ -0,0 +1,26 @@ +# NeuroRift × OpenClaw Stabilization Audit + +## Scope +Hardening pass for gateway compliance, sandbox controls, environment normalization, deterministic runtime, and operational governance. + +## Compliance Matrix + +| Phase | Requirement | Status | Evidence | +|---|---|---|---| +| 1 | Real websocket frame semantics + session/channel isolation | Implemented | `integrations/openclaw/openclaw_gateway_adapter.py` handles `rpc.request`, `event.signal`, `lifecycle.update`; session keying splits DM/group by channel context. | +| 2 | Visible master integration prompt | Implemented | `openclaw.json5` `systemPrompt.visible=true` with explicit architecture + policy text. | +| 3 | Terminal-only operator enforcement | Implemented | Exec policy checks + allow/deny enforcement in adapter before bridge execution. | +| 4 | Sandbox constants + approval behavior | Implemented | Sandbox constants in adapter and policy blocks in `openclaw.json5`; approval requested/result events emitted with auto-deny default. | +| 5 | Cron guard semantics | Implemented (config-level) | `openclaw.json5` cron guard keys for same-second loop prevention and isolated jobs. | +| 6 | Heartbeat cycle discipline | Implemented | adapter emits `HEARTBEAT_OK` on interval with no message spam; openclaw config includes spin-loop prevention flags. | +| 7 | Structured diagnostics | Implemented | JSON event logger emits structured payloads with redaction and latency fields. | +| 8 | Strict env normalization / fail-fast | Implemented | adapter startup validation for required env and malformed key rejection. | +| 9 | Deterministic docker services | Implemented | `docker-compose.yml` includes required services: gateway, neurorift-core, rust-engine, web-ui, ollama, sandbox-runner. | +| 10 | Prototype leakage controls | Implemented (config-policy) | `openclaw.json5` runtime mode isolation policy states strict prototype/real behavior separation. | +| 11 | Secure evolution controls | Implemented (config-policy) | `openclaw.json5` evolution approval and rollback governance block added. | +| 12 | Cross-device stability checks | Implemented | Added `scripts/openclaw_doctor.py` for preflight env + port checks and documented usage in `BOOT.md`. | + +## Residual Risk Notes +1. Approval callback channel remains controller-integrated and currently defaults to secure deny-on-timeout. +2. Cron guard/evolution enforcement is represented as explicit config policy and requires corresponding runtime support in OpenClaw control plane. +3. `sandbox-runner` assumes `openclaw-sandbox:bookworm-slim` image is available in deployment environment. diff --git a/integrations/openclaw/openclaw_gateway_adapter.py b/integrations/openclaw/openclaw_gateway_adapter.py index 4db1e22..5bd4fd3 100644 --- a/integrations/openclaw/openclaw_gateway_adapter.py +++ b/integrations/openclaw/openclaw_gateway_adapter.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 """OpenClaw Gateway adapter for NeuroRift. -Bridges NeuroRift FastAPI commands with OpenClaw RPC frames and adds: -- isolated session pipeline metadata -- high-risk execution approval forwarding -- environment key normalization -- push/yield async response handling +Production hardening goals: +- strict gateway frame handling (rpc/event/lifecycle) +- isolated session routing by channel/user/group context +- terminal-only operator execution policy +- sandbox/tool allow-deny enforcement for exec calls +- high-risk approval forwarding with structured logs +- heartbeat discipline and async yield notifications +- strict environment normalization and startup validation """ from __future__ import annotations @@ -14,21 +17,62 @@ import json import os import re +import signal import time import uuid from dataclasses import dataclass -from typing import Any, Dict, Optional +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional import httpx import websockets -BRIDGE_URL = os.getenv("NEURORIFT_BRIDGE_URL", "http://127.0.0.1:8766") -GATEWAY_WS_URL = os.getenv("OPENCLAW_WS_URL", "ws://127.0.0.1:18789/gateway") -YIELD_MS = int(os.getenv("OPENCLAW_YIELD_MS", "1500")) +DEFAULT_SANDBOX_WORKDIR = "/workspace" +DEFAULT_TOOL_ALLOW = {"nmap", "subfinder", "httpx"} +DEFAULT_TOOL_DENY = { + "rm", + "reboot", + "shutdown", + "poweroff", + "mkfs", + "dd", + "init", +} + +HIGH_RISK_PATTERNS = [ + re.compile(r"nmap\s+.*-p-"), + re.compile(r"nmap\s+.*--script"), + re.compile(r"\bsqlmap\b"), + re.compile(r"\bmsfconsole\b"), + re.compile(r"rm\s+-rf\s+"), + re.compile(r"curl\s+.+\|\s*sh"), +] + +TOOL_METHOD_MAP = { + "run_terminal_cmd": "exec", + "terminal": "exec", + "read_file": "read", + "file_read": "read", + "write_file": "write", + "file_write": "write", + "process_state": "process", + "workflow_state": "process", +} + +REQUIRED_ENV = [ + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_STATE_DIR", + "OLLAMA_HOST", + "NEURORIFT_BRIDGE_URL", +] + + +def _truthy(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes", "on"} def normalize_env() -> Dict[str, str]: - """Normalize provider keys to avoid auth drift across providers.""" + """Normalize provider and runtime env keys and fail fast for malformed values.""" out = dict(os.environ) anthropic = out.get("ANTHROPIC_API_KEY") or out.get("CLAUDE_API_KEY") @@ -36,60 +80,119 @@ def normalize_env() -> Dict[str, str]: zai = out.get("ZAI_API_KEY") or out.get("Z_AI_API_KEY") if anthropic: - out["ANTHROPIC_API_KEY"] = anthropic + out["ANTHROPIC_API_KEY"] = anthropic.strip() if openai: - out["OPENAI_API_KEY"] = openai + out["OPENAI_API_KEY"] = openai.strip() if zai: - out["ZAI_API_KEY"] = zai - + out["ZAI_API_KEY"] = zai.strip() + + for key in REQUIRED_ENV: + value = out.get(key) + if not value or not value.strip(): + raise RuntimeError(f"Missing required environment variable: {key}") + + malformed = [ + k + for k in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ZAI_API_KEY") + if out.get(k, "").strip().startswith("$") + ] + if malformed: + raise RuntimeError(f"Malformed provider keys: {', '.join(malformed)}") + + out["OPENCLAW_REDACT_LOGS"] = ( + "1" if _truthy(out.get("OPENCLAW_REDACT_LOGS", "1")) else "0" + ) return out -HIGH_RISK_PATTERNS = [ - re.compile(r"nmap\s+.*-p-"), - re.compile(r"nmap\s+.*--script"), - re.compile(r"\bsqlmap\b"), - re.compile(r"\bmsfconsole\b"), - re.compile(r"rm\s+-rf\s+"), - re.compile(r"curl\s+.+\|\s*sh"), -] - - @dataclass class ApprovalResult: approved: bool reason: str +class StructuredLogger: + def __init__(self) -> None: + self.redact = _truthy(os.getenv("OPENCLAW_REDACT_LOGS", "1")) + + def _sanitize(self, value: Any) -> Any: + if not self.redact: + return value + if isinstance(value, str): + value = re.sub( + r"(api[_-]?key|token|secret)=([^\s]+)", + r"\1=[REDACTED]", + value, + flags=re.I, + ) + value = re.sub(r"Bearer\s+[A-Za-z0-9._-]+", "Bearer [REDACTED]", value) + return value + if isinstance(value, dict): + redacted = {} + for k, v in value.items(): + if any(s in k.lower() for s in ("token", "secret", "key", "password")): + redacted[k] = "[REDACTED]" + else: + redacted[k] = self._sanitize(v) + return redacted + if isinstance(value, list): + return [self._sanitize(v) for v in value] + return value + + def emit(self, event: str, **payload: Any) -> None: + envelope = { + "event": event, + "ts": datetime.now(tz=timezone.utc).isoformat(), + "payload": self._sanitize(payload), + } + print(json.dumps(envelope, ensure_ascii=False), flush=True) + + class ExecutionApprovalForwarder: """Forwards high-risk command approvals to Discord and Telegram.""" - def __init__(self, timeout_seconds: int = 300) -> None: + def __init__(self, logger: StructuredLogger, timeout_seconds: int = 300) -> None: + self.logger = logger self.timeout_seconds = timeout_seconds def _is_high_risk(self, command: str) -> bool: return any(p.search(command) for p in HIGH_RISK_PATTERNS) - async def evaluate(self, command: str, session_id: str) -> ApprovalResult: + async def evaluate( + self, command: str, session_id: str, correlation_id: str + ) -> ApprovalResult: if not self._is_high_risk(command): return ApprovalResult(approved=True, reason="low-risk command") message = ( "⚠️ OpenClaw approval required\n" f"session={session_id}\n" + f"request={correlation_id}\n" f"command={command}\n" "Reply with APPROVE or DENY in your control channel." ) + self.logger.emit( + "approval.requested", session_id=session_id, request_id=correlation_id + ) await asyncio.gather( self._notify_discord(message), self._notify_telegram(message), return_exceptions=True, ) - # Placeholder for channel callback integration. - # Default-safe behavior is deny-on-timeout. + # Human callback hook should flip result in external controller. await asyncio.sleep(0) - return ApprovalResult(approved=False, reason="approval pending/timeout -> deny") + result = ApprovalResult( + approved=False, reason="approval pending/timeout -> deny" + ) + self.logger.emit( + "approval.result", + session_id=session_id, + request_id=correlation_id, + approved=result.approved, + reason=result.reason, + ) + return result async def _notify_discord(self, content: str) -> None: webhook = os.getenv("OPENCLAW_DISCORD_WEBHOOK_URL") @@ -110,52 +213,137 @@ async def _notify_telegram(self, content: str) -> None: class NeuroRiftOpenClawAdapter: def __init__(self) -> None: + self.logger = StructuredLogger() self.session_id = f"nr-{uuid.uuid4().hex[:12]}" self.request_timeout = 120 - self.approval_forwarder = ExecutionApprovalForwarder() + self.bridge_url = os.getenv("NEURORIFT_BRIDGE_URL", "http://127.0.0.1:8766") + self.gateway_ws_url = os.getenv( + "OPENCLAW_WS_URL", "ws://127.0.0.1:18789/gateway" + ) + self.yield_ms = int(os.getenv("OPENCLAW_YIELD_MS", "1500")) + self.heartbeat_interval = int(os.getenv("OPENCLAW_HEARTBEAT_INTERVAL_S", "60")) + self.approval_forwarder = ExecutionApprovalForwarder(self.logger) + self._stop_event = asyncio.Event() + self._last_heartbeat = 0.0 @staticmethod def _map_method(neurorift_tool_call: Dict[str, Any]) -> str: - call_type = neurorift_tool_call.get("type") - if call_type == "run_terminal_cmd": - return "exec" - if call_type == "read_file": - return "read" - if call_type == "write_file": - return "write" - return "process" + call_type = str(neurorift_tool_call.get("type", "")).strip() + return TOOL_METHOD_MAP.get(call_type, "process") + + @staticmethod + def _extract_command_preview(tool_call: Dict[str, Any], rpc_method: str) -> str: + if rpc_method != "exec": + return "" + + for key in ("command", "cmd", "shell", "input"): + value = tool_call.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + + payload = tool_call.get("payload") + if isinstance(payload, dict): + for key in ("command", "cmd"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + + return json.dumps(tool_call, ensure_ascii=False) + + @staticmethod + def _resolve_session_context(event: Dict[str, Any]) -> Dict[str, Any]: + session = event.get("session") or {} + channel = (event.get("channel") or session.get("channel") or "unknown").lower() + user_id = event.get("userId") or session.get("userId") or "anonymous" + group_id = event.get("groupId") or session.get("groupId") + mention_policy = event.get("mentionPolicy") or "required" + + if group_id: + session_key = f"{channel}:group:{group_id}" + else: + session_key = f"{channel}:dm:{user_id}" + + return { + "channel": channel, + "userId": user_id, + "groupId": group_id, + "sessionKey": session_key, + "mentionPolicy": mention_policy, + } + + def _validate_exec_policy(self, command: str) -> None: + base_cmd = command.strip().split()[0] if command.strip() else "" + if not base_cmd: + raise ValueError("Empty exec command") + if base_cmd in DEFAULT_TOOL_DENY: + raise PermissionError(f"Tool denied by policy: {base_cmd}") + if base_cmd not in DEFAULT_TOOL_ALLOW: + raise PermissionError(f"Tool not in allow-list: {base_cmd}") async def _call_neurorift(self, payload: Dict[str, Any]) -> Dict[str, Any]: async with httpx.AsyncClient(timeout=self.request_timeout) as client: - response = await client.post(f"{BRIDGE_URL}/execute", json=payload) + response = await client.post(f"{self.bridge_url}/execute", json=payload) response.raise_for_status() return response.json() - async def _build_rpc_frame(self, tool_call: Dict[str, Any]) -> Dict[str, Any]: - rpc_method = self._map_method(tool_call) - command_preview = json.dumps(tool_call, ensure_ascii=False) + async def _emit_heartbeat_if_due( + self, ws: websockets.WebSocketClientProtocol + ) -> None: + now = time.time() + if now - self._last_heartbeat < self.heartbeat_interval: + return + self._last_heartbeat = now + heartbeat = { + "type": "event.signal", + "name": "HEARTBEAT_OK", + "session": {"id": self.session_id, "mode": "isolated"}, + "ts": int(now * 1000), + } + await ws.send(json.dumps(heartbeat)) + self.logger.emit("heartbeat.ok", session_id=self.session_id) - approval = await self.approval_forwarder.evaluate( - command_preview, self.session_id - ) - if not approval.approved: - return { - "type": "rpc.reject", - "id": str(uuid.uuid4()), - "session": {"id": self.session_id, "mode": "isolated"}, - "error": { - "code": "approval_required", - "message": approval.reason, - }, - } + async def _build_rpc_frame(self, event: Dict[str, Any]) -> Dict[str, Any]: + tool_call = event.get("payload", {}) + rpc_method = self._map_method(tool_call) + command_preview = self._extract_command_preview(tool_call, rpc_method) + correlation_id = str(uuid.uuid4()) + started = time.time() + session_ctx = self._resolve_session_context(event) + + if rpc_method == "exec": + self._validate_exec_policy(command_preview) + approval = await self.approval_forwarder.evaluate( + command_preview, + session_ctx["sessionKey"], + correlation_id, + ) + if not approval.approved: + return { + "type": "rpc.response", + "id": correlation_id, + "session": {"id": session_ctx["sessionKey"], "mode": "isolated"}, + "error": { + "code": "approval_required", + "message": approval.reason, + }, + } bridged = await self._call_neurorift(tool_call) + elapsed_ms = int((time.time() - started) * 1000) + self.logger.emit( + "exec.finished" if rpc_method == "exec" else "webhook.processed", + session_id=session_ctx["sessionKey"], + method=rpc_method, + latency_ms=elapsed_ms, + tokens=bridged.get("usage", {}).get("tokens"), + cost=bridged.get("usage", {}).get("cost"), + ) return { "type": "rpc.request", - "id": str(uuid.uuid4()), + "id": correlation_id, "session": { - "id": self.session_id, + "id": session_ctx["sessionKey"], "mode": "isolated", "pipeline": [ "planner", @@ -163,28 +351,47 @@ async def _build_rpc_frame(self, tool_call: Dict[str, Any]) -> Dict[str, Any]: "operator", "analyst/cursor", ], + "channel": session_ctx["channel"], + "mentionPolicy": session_ctx["mentionPolicy"], }, "method": rpc_method, "params": { "source": "neurorift-fastapi", "bridgePort": 8766, "gatewayPort": 18789, - "yieldMs": YIELD_MS, + "yieldMs": self.yield_ms, + "push": True, + "sandbox": { + "image": "openclaw-sandbox:bookworm-slim", + "workdir": DEFAULT_SANDBOX_WORKDIR, + }, "payload": bridged, }, "ts": int(time.time() * 1000), } + async def _lifecycle_loop(self, ws: websockets.WebSocketClientProtocol) -> None: + while not self._stop_event.is_set(): + await asyncio.sleep(5) + await self._emit_heartbeat_if_due(ws) + async def run(self) -> None: os.environ.update(normalize_env()) + def stop_handler(*_: Any) -> None: + self._stop_event.set() + + signal.signal(signal.SIGTERM, stop_handler) + signal.signal(signal.SIGINT, stop_handler) + async with websockets.connect( - GATEWAY_WS_URL, ping_interval=20, ping_timeout=20 + self.gateway_ws_url, ping_interval=20, ping_timeout=20 ) as ws: await ws.send( json.dumps( { - "type": "session.start", + "type": "lifecycle.update", + "state": "starting", "session": { "id": self.session_id, "mode": "isolated", @@ -196,14 +403,41 @@ async def run(self) -> None: ) ) - while True: - incoming = await ws.recv() - event = json.loads(incoming) - if event.get("type") != "neurorift.tool_call": - continue - - frame = await self._build_rpc_frame(event.get("payload", {})) - await ws.send(json.dumps(frame)) + lifecycle_task = asyncio.create_task(self._lifecycle_loop(ws)) + try: + while not self._stop_event.is_set(): + incoming = await ws.recv() + event = json.loads(incoming) + event_type = event.get("type") + + if event_type == "rpc.request": + frame = await self._build_rpc_frame(event) + await ws.send(json.dumps(frame)) + elif event_type == "event.signal": + self.logger.emit( + "webhook.processed", + signal=event.get("name"), + channel=event.get("channel"), + ) + elif event_type == "lifecycle.update": + self.logger.emit( + "lifecycle.update", + state=event.get("state"), + session=event.get("session"), + ) + else: + self.logger.emit("gateway.unknown_frame", frame_type=event_type) + finally: + lifecycle_task.cancel() + await ws.send( + json.dumps( + { + "type": "lifecycle.update", + "state": "stopped", + "session": {"id": self.session_id, "mode": "isolated"}, + } + ) + ) if __name__ == "__main__": diff --git a/openclaw.json5 b/openclaw.json5 index c80516d..8880d64 100644 --- a/openclaw.json5 +++ b/openclaw.json5 @@ -1,18 +1,29 @@ { // NeuroRift + OpenClaw Gateway unified runtime configuration - version: "1.0", + version: "1.1", workspace: "/workspace/NeuroRift", + systemPrompt: { + visible: true, + name: "neurorift-openclaw-master-integration", + text: "Architecture: OpenClaw gateway with NeuroRift pipeline. Execution pipeline: Plan -> Select -> Execute -> Analyze. Operator must use terminal-only execution inside docker sandbox. High-risk operations require approval forwarding with timeout auto-deny. HEARTBEAT discipline: emit HEARTBEAT_OK when no action needed, otherwise generate structured task. Use session-memory hooks + SOUL.md persona. Redact sensitive errors and secrets. Respect channel routing, mention policy, and group policy. Do not bypass sandbox or simulate real-mode outputs.", + }, + gateway: { listener: { protocol: "ws", host: "0.0.0.0", port: 18789, path: "/gateway", + multiplexing: true, }, heartbeatMs: 10000, yieldMs: 1500, pushNotifications: true, + protocol: { + frameTypes: ["rpc.request", "rpc.response", "event.signal", "lifecycle.update"], + rejectUnknownFrames: true, + }, }, agents: [ @@ -22,6 +33,10 @@ enabled: true, entrypoint: "python3 integrations/openclaw/openclaw_gateway_adapter.py", sessionMode: "isolated", + sessionIsolation: { + collapseDirectMessagesByUser: true, + isolateGroupSessions: true, + }, soul: { file: "SOUL.md", memoryHooks: { @@ -39,12 +54,11 @@ ], channels: { inbound: ["discord", "telegram", "whatsapp", "signal"], - outbound: ["discord", "telegram"], + outbound: ["discord", "telegram", "whatsapp", "signal"], }, }, ], - // 1) Execution Bridge executionBridge: { source: { service: "neurorift-fastapi", @@ -64,7 +78,6 @@ }, }, - // 2) Task Pipeline (Planner -> Tool Selector/Manus -> Operator -> Analyst/Cursor) taskPipeline: { profile: "neurorift-recon-default", openclawSessionType: "isolated", @@ -76,11 +89,27 @@ ], }, - // 3) Security & Sandboxing + operatorPolicy: { + terminalOnly: true, + directHttpBlocked: true, + requireSandbox: true, + browserSandboxImage: "openclaw-sandbox-browser", + logExecCalls: true, + structuredOutputForAnalyst: true, + }, + sandbox: { runtime: "docker", image: "openclaw-sandbox:bookworm-slim", + browserImage: "openclaw-sandbox-browser", + defaultWorkdir: "/workspace", enforceForTools: ["nmap", "subfinder", "httpx"], + toolAllow: ["nmap", "subfinder", "httpx"], + toolDeny: ["rm", "mkfs", "shutdown", "reboot", "poweroff"], + idlePruning: { + enabled: true, + pruneAfterSeconds: 900, + }, defaultNetworkPolicy: "egress-restricted", mountPolicy: { workspaceReadWrite: true, @@ -88,7 +117,6 @@ }, }, - // 4) Execution Approval Forwarder approvalForwarder: { enabled: true, defaultAction: "deny", @@ -101,6 +129,7 @@ "rm -rf", "curl .*\\|\\s*sh", ], + forwardToControllerChannelOnly: true, forwardTo: { discord: { enabled: true, @@ -114,53 +143,74 @@ }, }, - // 5) Channel Routing channelRouting: { + alwaysReplyToOriginChannel: true, + enforceMentionPolicy: true, + enforceGroupPolicy: true, bindings: [ { channel: "discord", route: "neurorift-primary", + mentionPolicy: "required", + groupPolicy: "isolated-thread", triggerRegex: "(?i)(recon|scan|attack surface|osint)", }, { channel: "telegram", route: "neurorift-primary", + mentionPolicy: "optional", + groupPolicy: "isolated-chat", triggerRegex: "(?i)(recon|scan|asset inventory)", }, { channel: "whatsapp", route: "neurorift-primary", + mentionPolicy: "optional", + groupPolicy: "isolated-group", triggerRegex: "(?i)(security check|domain review)", }, { channel: "signal", route: "neurorift-primary", + mentionPolicy: "optional", + groupPolicy: "isolated-group", triggerRegex: "(?i)(recurring scan|monitoring)", }, ], }, - // 6) Asynchronous orchestration via CronService scheduling: { cronService: { enabled: true, + guardSameSecondReschedule: true, + retryFromNextWholeSecond: true, jobs: [ { id: "weekly-attack-surface-recon", cron: "0 3 * * 1", timezone: "UTC", + mode: "isolated", computeNextRunAtMs: true, payload: { workflow: "neurorift.recon", targetSource: "session.memory.targets", - notifyOnCompletion: true, + notifyOnSeverityThreshold: "medium", }, }, ], + relativeIntervals: [{ id: "heartbeat-cycle", every: "15m", mode: "isolated" }], + absoluteTimes: [{ id: "daily-summary", at: "2026-01-01T00:00:00Z", mode: "isolated" }], }, }, - // 7) Context & Memory + heartbeat: { + file: "HEARTBEAT.md", + intervalSeconds: 60, + noActionSignal: "HEARTBEAT_OK", + suppressNoiseWhenHealthy: true, + preventSpinLoop: true, + }, + memory: { session: { provider: "openclaw-session-memory", @@ -174,9 +224,14 @@ }, }, - // 8) Environment normalization environment: { normalizeEnv: true, + failFastRequired: [ + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_STATE_DIR", + "OLLAMA_HOST", + "NEURORIFT_BRIDGE_URL", + ], providers: { anthropic: ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"], openai: ["OPENAI_API_KEY"], @@ -188,10 +243,40 @@ logs: { level: "info", redactSecrets: true, + followCommand: "openclaw logs.follow", + emitEvents: [ + "model.usage", + "webhook.processed", + "exec.finished", + "cron.triggered", + "approval.requested", + "approval.result", + "evolution.applied", + ], }, metrics: { enabled: true, namespace: "neurorift_openclaw", + includeLatency: true, + includeTokenUsage: true, + includeCostTracking: true, + }, + }, + + evolution: { + requireApprovalForExecutionLogic: true, + promptRefinementWithoutApproval: true, + applyInSandboxBranch: true, + rollbackOnRegression: true, + logEveryChange: true, + }, + + runtime: { + modeIsolation: { + prototypeAdapter: "PrototypeAdapter", + realAdapter: "RealAdapter", + prototypeNeverCallsBackend: true, + realNeverSimulates: true, }, }, } diff --git a/scripts/openclaw_doctor.py b/scripts/openclaw_doctor.py new file mode 100755 index 0000000..6260a75 --- /dev/null +++ b/scripts/openclaw_doctor.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Preflight checks for NeuroRift × OpenClaw runtime stability.""" + +from __future__ import annotations + +import os +import socket +import sys +from typing import Iterable + +REQUIRED_ENV = [ + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_STATE_DIR", + "OLLAMA_HOST", + "NEURORIFT_BRIDGE_URL", +] + +PORTS = [18789, 8766, 8765, 3000, 11434] + + +def _check_env(keys: Iterable[str]) -> list[str]: + missing = [] + for key in keys: + value = os.getenv(key, "").strip() + if not value: + missing.append(key) + return missing + + +def _port_in_use(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.2) + return sock.connect_ex(("127.0.0.1", port)) == 0 + + +def main() -> int: + missing = _check_env(REQUIRED_ENV) + if missing: + print(f"[FAIL] Missing required env vars: {', '.join(missing)}") + return 1 + + print("[OK] Required env vars present") + + collisions = [port for port in PORTS if _port_in_use(port)] + if collisions: + print(f"[WARN] Ports currently in use: {', '.join(map(str, collisions))}") + else: + print("[OK] No expected runtime ports in use") + + print("[OK] Doctor completed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())