Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions plugins/platforms/carbonvoice/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .setup import register

__all__ = ["register"]
2,303 changes: 2,303 additions & 0 deletions plugins/platforms/carbonvoice/adapter.py

Large diffs are not rendered by default.

811 changes: 811 additions & 0 deletions plugins/platforms/carbonvoice/api.py

Large diffs are not rendered by default.

161 changes: 161 additions & 0 deletions plugins/platforms/carbonvoice/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Allowlist gating + ignored-sender audit log.

Hermes core already enforces ``CARBONVOICE_ALLOWED_USERS`` /
``CARBONVOICE_ALLOW_ALL_USERS`` *after* the adapter dispatches. We
replicate the check inside the adapter so we can:

1. Short-circuit before the agent ever sees the message (cheaper).
2. Record the rejection in an append-only audit log with the resolved
username, so the operator can see who's trying to reach the bot.

Log path defaults to ``$HERMES_HOME/logs/carbonvoice-ignored-senders.log``
and is one JSON object per line: ``{"time", "user_id", "username", "channel_id"}``.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Set

if TYPE_CHECKING:
from .channels import ChannelCache
from .permits import ApprovalStore

logger = logging.getLogger(__name__)


class AllowlistGate:
"""**Deny-by-default** access control for inbound Carbon Voice messages.

A user may reach the agent if ANY of these holds:

1. **allow-all** is explicitly enabled (``CARBONVOICE_ALLOW_ALL_USERS=true``)
— the escape hatch back to the old open behavior.
2. they are the **owner** — ``whoami.created_by``, the user who created
the bot account. Auto-detected at connect; always allowed. This is
what makes deny-by-default usable without any manual setup.
3. they are in ``CARBONVOICE_ALLOWED_USERS`` (static env list).
4. they were **approved at runtime** via ``/cv-allow`` — the
:class:`~permits.ApprovalStore` (Hermes core's ``PairingStore``).

Default (no config) → **only the owner**. This closes the security hole
where anyone on a shared channel could ask the agent to read or run
things on the host. The owner grows the list interactively from the
home channel (``/cv-allow <id>``) without restarting.

History: the default used to be allow-all (``CARBONVOICE_ALLOW_ALL_USERS``
defaulted true / was an opt-out). It is now an opt-IN. Existing
deployments with an empty allow-list will, after this change, only
answer the owner until they approve others — see README/CHANGELOG.
"""

def __init__(
self,
allow_all: bool,
allowed_ids: Set[str],
approvals: Optional["ApprovalStore"] = None,
):
self._allow_all = allow_all
self._allowed_ids = allowed_ids
self._approvals = approvals
self._owner_id: Optional[str] = None # set at connect via set_owner()

@classmethod
def from_env(
cls, approvals: Optional["ApprovalStore"] = None
) -> "AllowlistGate":
raw = os.getenv("CARBONVOICE_ALLOWED_USERS", "")
allowed = {u.strip() for u in raw.split(",") if u.strip()}
# allow-all is now an explicit opt-IN (truthy enables it); deny is
# the default.
allow_all = os.getenv("CARBONVOICE_ALLOW_ALL_USERS", "").strip().lower() in (
"true", "1", "yes", "on",
)
return cls(allow_all=allow_all, allowed_ids=allowed, approvals=approvals)

def set_owner(self, owner_id: Optional[str]) -> None:
"""Record the bot owner (``whoami.created_by``). Always allowed."""
self._owner_id = (owner_id or "").strip() or None

@property
def owner_id(self) -> Optional[str]:
return self._owner_id

def is_owner(self, user_id: Optional[str]) -> bool:
return bool(self._owner_id and user_id and user_id == self._owner_id)

@property
def has_any_authorizer(self) -> bool:
"""True if *anyone* can be allowed (owner / env list / allow-all).

When this is False after connect, deny-by-default would mute the bot
for everyone — the adapter logs a loud bootstrap warning.
"""
return bool(self._allow_all or self._owner_id or self._allowed_ids)

def is_allowed(self, user_id: Optional[str]) -> bool:
if self._allow_all:
return True
if not user_id:
return False
if self._owner_id and user_id == self._owner_id:
return True
if user_id in self._allowed_ids:
return True
if self._approvals is not None and self._approvals.is_approved(user_id):
return True
return False


class IgnoredSenderLog:
"""Append-only JSON-lines log of rejected inbound senders."""

def __init__(self, path: Path, channels: "ChannelCache"):
self._path = path
self._channels = channels

@property
def path(self) -> Path:
return self._path

def record(self, user_id: str, channel_id: Optional[str] = None) -> None:
"""Fire-and-forget — never blocks the inbound path."""
asyncio.create_task(self._record(user_id, channel_id))

async def _record(self, user_id: str, channel_id: Optional[str]) -> None:
try:
# Resolve the name from the channel roster when we have a
# channel; an unauthorized sender may not be a collaborator,
# in which case this is None and we log just the guid.
username = ""
if user_id and channel_id:
username = (
await self._channels.resolve_name(channel_id, user_id) or ""
)
entry = {
"time": datetime.now(timezone.utc).isoformat(),
"user_id": user_id,
"username": username,
}
if channel_id:
entry["channel_id"] = channel_id
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._path.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except Exception as exc:
logger.debug("carbonvoice: ignored-sender log failed: %s", exc)


def default_ignored_log_path() -> Path:
"""``$HERMES_HOME/logs/carbonvoice-ignored-senders.log`` with safe fallback."""
try:
from hermes_constants import get_hermes_home
home = get_hermes_home()
except Exception:
home = Path.home() / ".hermes"
return home / "logs" / "carbonvoice-ignored-senders.log"
117 changes: 117 additions & 0 deletions plugins/platforms/carbonvoice/channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""In-memory cache for Carbon Voice channel metadata.

One ``GET /channel/{id}`` per channel populates two things, both keyed by
``channel_id`` and cached for the process lifetime:

- **chat_type** ("dm" | "group") — channel kind almost never changes
after creation (a DM stays a DM forever).
- **roster** (``{user_guid → display name}``) — derived from the
channel's ``json_collaborators``. This is the canonical way to
resolve participant names: the standalone ``GET /v3/users/{id}``
endpoint is dead (404), and the collaborator list rides on the same
payload we already fetch for chat-type, so names cost zero extra
calls.

The first message in a new channel pays one API call; within the TTL
window every message after is free for both axes. A failed *initial*
lookup caches ``"dm"`` + an empty roster so the adapter degrades
gracefully (keeps responding, falls back to the raw guid for names)
rather than re-hitting the API per message.

TTL: the payload is refreshed after ``ttl_s`` (default 30 min) so a
participant who joins mid-conversation shows up in the roster without a
gateway restart. ``chat_type`` is immutable so re-fetching it is wasted
but harmless — keeping one cache policy is simpler than two. A failed
*refresh* keeps the prior good values (we don't blow a known roster away
with an empty one on a transient hiccup).
"""

from __future__ import annotations

import logging
import time
from typing import Dict, Optional

from .api import CarbonVoiceAPI
from .parse import chat_type_from_channel, extract_roster

logger = logging.getLogger(__name__)

# 30 min — long enough that a busy channel stays cache-warm, short enough
# that a new joiner is picked up within a reasonable window. Mirrors the
# thread-context TTL in conversations.py.
DEFAULT_CHANNEL_TTL_S = 1800


class ChannelCache:
def __init__(
self, api: CarbonVoiceAPI, *, ttl_s: int = DEFAULT_CHANNEL_TTL_S
):
self._api = api
self._type_cache: Dict[str, str] = {}
self._roster_cache: Dict[str, Dict[str, str]] = {}
self._loaded_at: Dict[str, float] = {}
self._ttl_s = ttl_s

async def _ensure_loaded(self, channel_id: str) -> None:
"""Fetch the channel and populate both caches, honoring the TTL.

Returns early when a cached entry is still within ``ttl_s``. On a
refresh that fails, the prior cached values are kept (and the
timestamp bumped so we don't hammer the API on repeated failures).
"""
now = time.monotonic()
loaded = self._loaded_at.get(channel_id)
if loaded is not None and (now - loaded) <= self._ttl_s:
return
try:
data = await self._api.get_channel(channel_id)
except Exception as exc:
logger.debug(
"carbonvoice: get_channel(%s) failed: %s", channel_id, exc
)
data = None
if data is None and channel_id in self._type_cache:
# Refresh failed but we have prior good values — keep them.
self._loaded_at[channel_id] = now
return
self._type_cache[channel_id] = chat_type_from_channel(data)
self._roster_cache[channel_id] = extract_roster(data)
self._loaded_at[channel_id] = now

async def resolve_chat_type(self, channel_id: str) -> str:
"""Return ``"dm"`` or ``"group"`` for *channel_id*.

Defaults to ``"dm"`` on any lookup failure so the agent keeps
responding (previous behavior) rather than going silent because of
a transient channel-API hiccup.
"""
if not channel_id:
return "dm"
await self._ensure_loaded(channel_id)
return self._type_cache.get(channel_id, "dm")

async def get_roster(self, channel_id: str) -> Dict[str, str]:
"""Return ``{user_guid → display name}`` for *channel_id*.

Empty dict on lookup failure. Shares the cached channel payload
with :meth:`resolve_chat_type`, so calling both for one message is
a single HTTP call.
"""
if not channel_id:
return {}
await self._ensure_loaded(channel_id)
return self._roster_cache.get(channel_id, {})

async def resolve_name(
self, channel_id: str, user_guid: str
) -> Optional[str]:
"""Display name for *user_guid* in *channel_id*, or ``None``.

``None`` means "not in this channel's collaborator list" — callers
fall back to the raw guid.
"""
if not channel_id or not user_guid:
return None
roster = await self.get_roster(channel_id)
return roster.get(user_guid)
95 changes: 95 additions & 0 deletions plugins/platforms/carbonvoice/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Carbon Voice plugin defaults shared across modules."""

from __future__ import annotations

import re
from pathlib import Path

DEFAULT_BASE_URL = "https://api.carbonvoice.app"
DEFAULT_POLL_INTERVAL_MS = 5_000
DEFAULT_WS_RETRY_INITIAL_MS = 1_000
DEFAULT_WS_RETRY_MAX_MS = 30_000
DEFAULT_SEEN_TTL_S = 5 * 60
DEFAULT_FLUSH_DEBOUNCE_S = 5.0

# How long a gate-rejected *voice* message in a group may stay revisit-held
# (cursor pinned, re-evaluated each tick) waiting for its picker tags.
# Flutter applies tags via the batch PUT only after STT (~10–30s after
# create); within this window a "no mention" verdict is provisional. Text
# messages carry tags on the create body and never hold. Override with
# CARBONVOICE_REVISIT_MAX_AGE_S.
DEFAULT_REVISIT_MAX_AGE_S = 90

# Delay before the one-shot self-scheduled re-tick that retries stuck /
# revisit-held messages. Keeps retries flowing in WS mode (where polling is
# stopped) without waiting for the next unrelated socket event.
STUCK_RETRY_DELAY_S = 6.0

# How long a message may stay "stuck" (no transcript yet) before we stop
# holding the cursor for it. CV usually finishes transcribing within
# seconds; a message with no transcript after this window almost certainly
# never will (image-only / system / failed STT). Past the cutoff we let it
# pass so it can't pin the cursor forever and re-feed the whole window on
# every poll/restart. Override with CARBONVOICE_STUCK_MAX_AGE_S.
DEFAULT_STUCK_MAX_AGE_S = 5 * 60
HTTP_TIMEOUT = 30.0
MAX_MESSAGE_LENGTH = 8000

# Request-source headers so the backend can categorize traffic per client
# (mirrors the Flutter app's lowercase-hyphenated headers like ``platform``
# and ``mobile-app-version``). ``agent-name`` is static — it identifies the
# integration type (hermes vs openclaw vs cloud-channel vs the apps).
# ``agent-id`` is dynamic — the bot account's user_guid from /whoami,
# injected once known so traffic can also be grouped per agent account.
AGENT_NAME_HEADER = "agent-name"
AGENT_NAME_VALUE = "hermes"
AGENT_ID_HEADER = "agent-id"


def _plugin_version() -> str:
try:
text = (Path(__file__).parent / "plugin.yaml").read_text()
match = re.search(r"^version:\s*(\S+)", text, re.MULTILINE)
if match:
return match.group(1)
except OSError:
pass
return "unknown"


# The backend's request logger only captures a fixed header set (ua,
# mobile-app-version, platform), so the User-Agent is what actually lets it
# categorize Hermes traffic today — agent-name/agent-id above are sent for
# when the backend starts logging them. After /whoami the api client appends
# " (agent-id: <guid>)" so the ua field also distinguishes agent accounts.
USER_AGENT = f"hermes-plugin/{_plugin_version()}"

# Carbon Voice's API gateway intermittently returns 502/503/504 (observed in
# bursts). A transient 5xx on a latency-critical GET/reaction would otherwise
# wait for the next poll tick (~5s) to recover; a couple of fast retries with
# short backoff recover in well under a second. Only idempotent reads/reactions
# retry — sends do NOT (a retried send could duplicate a delivered message).
TRANSIENT_RETRY_ATTEMPTS = 2
TRANSIENT_RETRY_BACKOFF_S = 0.4
TRANSIENT_STATUS = (502, 503, 504)

# "acknowledged" is a built-in Carbon Voice reaction id — works out of the
# box without operator config. Override with CARBONVOICE_REACTION_ID after
# inspecting the available reactions logged on startup.
DEFAULT_REACTION_ID = "acknowledged"

# "confused" (⁉️) is a built-in CV reaction. We put it on an unauthorized
# sender's first message as a silent "we saw you, you're pending approval"
# signal — instead of posting a text reply that clutters the channel and
# spams every old conversation when deny-by-default re-flags them. Override
# with CARBONVOICE_PENDING_REACTION_ID.
DEFAULT_PENDING_REACTION_ID = "confused"

# One-tap owner approval: instead of copying "/cv-allow-user <id>", the owner
# just reacts on the bot's "X wants to talk to me" prompt — 💯 to allow, 👎 to
# block. Mirrors cv-claude-channels' reaction-based permission relay. These
# are CV built-in reaction *ids* (the id is what counts; CV stores the
# "negative" reaction with code ⛔ but clients render it as a thumbs-down 👎);
# override via CARBONVOICE_APPROVE_REACTION_ID / CARBONVOICE_REJECT_REACTION_ID.
DEFAULT_APPROVE_REACTION_ID = "affirmative" # 💯
DEFAULT_REJECT_REACTION_ID = "negative" # 👎 (stored code ⛔)
Loading