Skip to content
Merged
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
48 changes: 41 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ replayed via `POST /api/v1/sync` on the next connect.
uv pip install 'git+https://github.com/openmaxai/hermes-openmax.git@v0.1.5'
# Or from a checkout:
uv pip install -e /path/to/hermes-openmax
# The plugin is discovered through the Hermes entry point. For a directory
# checkout, this symlink is also supported:
# The plugin is discovered through the Hermes entry point. For a trusted
# directory checkout, this symlink is also supported; keep the sibling
# cws_agent_sdk/ directory in the checkout and install httpx + websockets in
# the Hermes environment:
ln -s /path/to/hermes-openmax/hermes_openmax ~/.hermes/plugins/hermes-openmax
# enable hermes-openmax in ~/.hermes/config.yaml, then restart:
hermes gateway restart
```

Directory loading bootstraps only the checkout's sibling `cws_agent_sdk`
package. It never installs dependencies at runtime. If that sibling package is
missing, startup logs an explicit installation error instead of leaving the
OpenMax platform silently offline. Prefer `hermes plugins install` or the pip
installation above for managed environments.

Put the secret in `~/.hermes/.env`:

```dotenv
Expand Down Expand Up @@ -184,8 +192,9 @@ your local environment; never paste tokens into chat or commit them. See
and deduplicated by conversation, work item, action, and resulting status.
Missing source context, read-only actions, and notification failures do not
break the underlying task operation.
- `send(metadata=...)` passes causation/interaction metadata through unchanged;
the server currently treats that metadata as opaque.
- `send(metadata=...)` preserves caller causation/interaction fields and adds
`agent_hop_count`, `agent_origin_member_id`, and `agent_trace_id` for the loop
guard; the server currently treats that metadata as opaque.
- Core is authoritative for the Agent owner. The bridge reconciles the local
`policy.json` cache at startup, every five minutes, after an internal WebSocket
reconnect, and when an `agent.config.owner_changed` refresh hint arrives. A
Expand All @@ -205,15 +214,40 @@ attachments. `workspace_members` provides directory, DM policy, and organization
management. Connection remains explicitly unsupported: hermes-openmax does not
register a Connection/`conn` tool and must not request credentials or simulate that surface.

Human access policy follows the Zylos safety contract: DM defaults to `owner`,
group scope defaults to `allowlist`, `disabled` blocks every group sender, and
an owner mention may bypass only a missing group registration. Owners remain
exempt from a configured group's `allowFrom`. Plain-text mentions use the Core
display name plus optional `CWS_SELF_ALIASES`, with a boundary check that keeps
`@Name` from matching `@NameSuffix`.

Agent-to-Agent traffic intentionally has an additional Hermes loop guard. It is
fail-closed unless the relevant DM/group switch is enabled and the sender is in
`CWS_ALLOWED_AGENT_SENDERS`; Agent group traffic also requires a structured
mention and still passes group scope, registration, and `allowFrom`. The bridge
enforces propagated hop limits plus local duplicate and per-sender/conversation
turn budgets. Sender identity and structured mentions are authenticated by CWS;
hop metadata is defense in depth, not an authentication boundary.

`silent` is bridge-only observation: admitted text updates a bounded in-memory
group history and the sync/read watermarks, but does not download attachments,
resolve work references, add an ack reaction, invoke billing, create a Hermes
turn, call the model, or reply. A later admitted message may receive the recent
bounded history as context. The vendored v1 conformance corpus still represents
this admission as `handle:true`; `contract/PROVENANCE.md` documents the Hermes
runtime overlay that consumes it before the host callback.

OpenMax WebSocket connectivity is transport health, not an installed IM channel.
This adapter therefore does not call the channel-liveness snapshot endpoint. A
runtime that owns IM channel processes must report its complete catalog-backed
snapshot (for example, Feishu and Telegram) from their actual process health.

OpenMax group ingress is upstream-authorized by CWS. Group messages therefore
OpenMax group ingress is authenticated by CWS and then checked by the SDK bridge.
Group messages therefore
intentionally have no per-member Hermes `user_id`; this preserves one shared
session per group while OpenMax enforces group scope, group allowlist,
allow-from, mention, smart, and silent policy before delivery. DM sessions
session per group while the bridge enforces group scope, group allowlist,
allow-from, mention, smart, Agent loop guards, and silent policy before runtime
delivery. DM sessions
remain user/conversation-scoped.

The bundled `hermes_openmax/skills/` docs preserve role boundaries,
Expand Down
11 changes: 11 additions & 0 deletions contract/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,14 @@ Vendored from https://github.com/openmaxai/openmax-agent-sdk
Per that repo's CONTRACT.md, passing fixtures/v1 against schemas/v1 is the
definition of protocol conformance for any SDK in any language. Do not edit
these files here — re-vendor from upstream and note the new commit.

## Hermes runtime overlay

The vendored v1 corpus classifies `silent` as an admitted `handle:true` policy
decision. Hermes preserves that normalization result for conformance, but its
production bridge consumes the admitted message into bounded bridge-owned
history and advances watermarks before the host callback. It does not deliver
the message into a Hermes session/model turn. This intentional runtime overlay
implements bridge-only observation without modifying the vendored contract;
changing the cross-runtime schema or fixtures requires an upstream SDK change
and a later re-vendor.
95 changes: 72 additions & 23 deletions cws_agent_sdk/access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
default False to prevent agent-to-agent chat loops.
- Group: handle only when the agent is mentioned (@agent / all / all_agents),
unless group_require_mention is disabled.
- Messages from SYSTEM senders: surfaced as handle=False by default
(delivered separately if the adapter wants lifecycle events).
- Messages from SYSTEM senders: delivered by default for scheduler/lifecycle
work and never participate in owner auto-binding.
- Own messages: never handled (also enforced upstream in the bridge).
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Optional
from typing import Iterable, Optional

from .types import InboundMessage

Expand All @@ -24,19 +25,25 @@
class AccessPolicyConfig:
# DM admission: "open" (any org member), "allowlist" (dm_allowlist +
# owner), "owner" (bound owner only — zylos's default private model).
dm_policy: str = "open"
dm_policy: str = "owner"
group_require_mention: bool = True
allow_agent_senders: bool = False # let other agents' messages trigger us
allow_sibling_dm: bool = False # same-owner agent DMs
agent_allowlist: list[str] = field(default_factory=list)
max_agent_hops: int = 4
agent_turn_budget: int = 4
agent_turn_window_s: float = 60.0
agent_duplicate_window_s: float = 60.0
dm_allowlist: list[str] = field(
default_factory=list
) # member_ids (dm_policy=allowlist)
# System Member DMs (scheduler "dependencies ready", issue.activated, ...)
# DRIVE the task flow — zylos lets them straight through. Default True.
handle_system: bool = True
group_policy: str = "open" # open | allowlist | disabled
group_policy: str = "allowlist" # open | allowlist | disabled
group_configs: dict[str, dict] = field(default_factory=dict)
self_display_name: str = ""
self_aliases: list[str] = field(default_factory=list)


@dataclass
Expand All @@ -45,7 +52,19 @@ class AccessDecision:
reason: str


def _is_mentioned(msg: InboundMessage, self_member_id: str) -> bool:
def _text_mentions_name(text: str, name: str) -> bool:
if not text or not name:
return False
# Do not let @Name match @NameSuffix or @Name-team. Python's Unicode-aware
# \w also keeps the boundary correct for non-ASCII display names.
return bool(re.search(r"@" + re.escape(name) + r"(?![\w-])", text, re.IGNORECASE))


def _is_mentioned(
msg: InboundMessage,
self_member_id: str,
self_names: Iterable[str] = (),
) -> bool:
for m in msg.mentions or []:
if isinstance(m, str) and m == self_member_id:
return True
Expand All @@ -61,6 +80,24 @@ def _is_mentioned(msg: InboundMessage, self_member_id: str) -> bool:
)
if str(target or "") == self_member_id:
return True
return any(_text_mentions_name(msg.text or "", name) for name in self_names if name)


def _is_directly_mentioned(msg: InboundMessage, self_member_id: str) -> bool:
"""Structured member mention only; broadcast mentions do not trigger Agents."""
for mention in msg.mentions or []:
if isinstance(mention, str) and mention == self_member_id:
return True
if not isinstance(mention, dict):
continue
target = (
mention.get("member_id")
or mention.get("entity_id")
or mention.get("mentioned_id")
or mention.get("id")
)
if str(target or "") == self_member_id:
return True
return False


Expand All @@ -84,21 +121,25 @@ def decide_inbound(
return AccessDecision(cfg.handle_system, "system_sender")

if msg.sender_type == "agent":
agent_allowed = "*" in cfg.agent_allowlist or msg.sender_id in cfg.agent_allowlist
if conv_type == "dm":
if (
cfg.allow_sibling_dm
and agent_allowed
and owner_member_id
and sender_owner_member_id == owner_member_id
):
return AccessDecision(True, "sibling_dm_allowed")
return AccessDecision(False, "agent_dm_blocked")
# Group: an agent sender only triggers us when explicitly allowed AND
# we are mentioned — both gates guard against agent-to-agent loops.
if cfg.allow_agent_senders and _is_mentioned(msg, self_member_id):
return AccessDecision(True, "agent_mention")
return AccessDecision(False, "agent_sender_blocked")
# Agent group traffic has an extra loop guard. Once admitted it still
# passes through the ordinary group scope/allowlist/allowFrom gates.
if (
not cfg.allow_agent_senders
or not agent_allowed
or not _is_directly_mentioned(msg, self_member_id)
):
return AccessDecision(False, "agent_sender_blocked")

# Human sender.
if conv_type == "dm":
if is_owner:
return AccessDecision(True, "dm_owner") # owner always exempt
Expand All @@ -112,18 +153,18 @@ def decide_inbound(
return AccessDecision(True, "dm")

# Group / broadcast / bridge conversations.
mentioned = _is_mentioned(msg, self_member_id)
if not mentioned and cfg.self_display_name:
mentioned = (
f"@{cfg.self_display_name}".casefold() in (msg.text or "").casefold()
)
if is_owner and mentioned:
return AccessDecision(True, "group_owner_mention") # owner @-bypass
self_names = (
(cfg.self_display_name, *cfg.self_aliases)
if msg.sender_type == "human"
else ()
)
mentioned = _is_mentioned(msg, self_member_id, self_names)
group = cfg.group_configs.get(msg.conversation_id)
policy = (cfg.group_policy or "open").lower()
policy = (cfg.group_policy or "allowlist").lower()
if policy == "disabled":
return AccessDecision(False, "group_disabled")
if policy == "allowlist" and group is None:
owner_mention_bypass = policy == "allowlist" and group is None and is_owner and mentioned
if policy == "allowlist" and group is None and not owner_mention_bypass:
return AccessDecision(False, "group_not_allowlisted")
group = group or {}
allow_from = [
Expand All @@ -135,13 +176,21 @@ def decide_inbound(
)
or []
]
if allow_from and "*" not in allow_from and msg.sender_id not in allow_from:
if (
allow_from
and "*" not in allow_from
and msg.sender_id not in allow_from
and not is_owner
):
return AccessDecision(False, "group_sender_not_allowed")
mode = str(group.get("mode") or "").lower()
if mode == "silent":
return AccessDecision(True, "group_silent")
if mode == "smart" or not cfg.group_require_mention:
return AccessDecision(True, "group_open")
if mentioned:
return AccessDecision(True, "group_mention")
return AccessDecision(
True,
"group_owner_mention" if owner_mention_bypass else "group_mention",
)
return AccessDecision(False, "group_no_mention")
Loading
Loading