New: app/control.py
- ControlConfig: operator_allowlist + control_rooms (frozensets)
- parse_control_config(): validates @user:server + !room:server formats, fail-fast
- parse_command(): parses !verb subcommand [args] [key=value] up to 512 chars
- check_authorization(): AND(is_control_room, is_operator) → (bool, reason)
- Reply helpers: not_implemented, unknown_command, unauthorized, help
- KNOWN_VERBS: runbook, status, help (M3.1+ stubs)
- MAX_CMD_LEN=512, MAX_CMD_TOKENS=20
ingress.py:
- _try_control(): dispatch for control rooms (authorized → audit + reply, unauthorized → audit + optional ⛔)
- join control rooms on startup
- _enqueue_from_sync: control rooms processed first, never forwarded to agents
- on_control_command(sender, verb, subcommand) metric callback
- CONTROL_UNAUTHORIZED_BEHAVIOR: "ignore" | "reply_error"
Audit events:
matrix.control.command — authorised command (verb, subcommand, args, kwargs)
matrix.control.unauthorized — rejected by allowlist (reason: not_operator | not_control_room)
matrix.control.unknown_cmd — authorised but unrecognised verb
Config + main:
- bridge_operator_allowlist, bridge_control_rooms, control_unauthorized_behavior
- matrix_bridge_control_commands_total{sender,verb,subcommand} counter
- /health: control_channel section (enabled, rooms_count, operators_count, behavior)
- /bridge/mappings: control_rooms + control_operators_count
- docker-compose: BRIDGE_OPERATOR_ALLOWLIST, BRIDGE_CONTROL_ROOMS, CONTROL_UNAUTHORIZED_BEHAVIOR
Tests: 40 new → 148 total pass
Made-with: Cursor
264 lines
9.3 KiB
Python
264 lines
9.3 KiB
Python
"""
|
|
Matrix Bridge — Control Command Layer (M3.0)
|
|
|
|
Handles operator commands from designated control rooms.
|
|
|
|
Access policy (AND):
|
|
1. Message arrives in a BRIDGE_CONTROL_ROOM
|
|
2. Sender is in BRIDGE_OPERATOR_ALLOWLIST
|
|
3. Message starts with "!" prefix (e.g. "!runbook start ...")
|
|
|
|
Design principles:
|
|
- Bridge is a TRANSPORT only — it never executes scripts directly.
|
|
- All actions go via sofiia-console internal API (M3.1+).
|
|
- Every command attempt is audited regardless of authorization.
|
|
- Unknown commands acknowledged but not executed (forward-compatible).
|
|
|
|
Audit events emitted:
|
|
matrix.control.command — authorised command recognised
|
|
matrix.control.unauthorized — command from non-operator or wrong room
|
|
matrix.control.unknown_cmd — authorised but unrecognised verb
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, FrozenSet, List, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
# Supported control verbs (M3.1+ will implement them fully)
|
|
VERB_RUNBOOK = "runbook"
|
|
VERB_STATUS = "status"
|
|
VERB_HELP = "help"
|
|
|
|
KNOWN_VERBS: FrozenSet[str] = frozenset({VERB_RUNBOOK, VERB_STATUS, VERB_HELP})
|
|
|
|
# Max command line length to guard against garbage injection
|
|
_MAX_CMD_LEN = 512
|
|
# Max number of tokens in a single command
|
|
_MAX_CMD_TOKENS = 20
|
|
|
|
# Matrix user ID format: @localpart:server
|
|
_MATRIX_USER_RE = re.compile(r"^@[A-Za-z0-9._\-/=+]+:[A-Za-z0-9.\-]+$")
|
|
# Room ID format: !localpart:server
|
|
_ROOM_ID_RE = re.compile(r"^![A-Za-z0-9\-_.]+:[A-Za-z0-9\-_.]+$")
|
|
|
|
|
|
# ── Data structures ────────────────────────────────────────────────────────────
|
|
|
|
@dataclass(frozen=True)
|
|
class ControlCommand:
|
|
"""Parsed control command from a Matrix message."""
|
|
verb: str # e.g. "runbook"
|
|
subcommand: str # e.g. "start", "next", "complete", "evidence", "status"
|
|
args: Tuple[str, ...] # remaining positional args
|
|
kwargs: Dict[str, str] # key=value pairs parsed from args (e.g. node=NODA1)
|
|
raw: str # original message text
|
|
is_known: bool # True if verb in KNOWN_VERBS
|
|
|
|
@classmethod
|
|
def from_tokens(cls, tokens: List[str], raw: str) -> "ControlCommand":
|
|
"""Build ControlCommand from pre-split tokens (first token must not include '!')."""
|
|
verb = tokens[0].lower() if tokens else ""
|
|
subcommand = tokens[1].lower() if len(tokens) > 1 else ""
|
|
remaining = tokens[2:] if len(tokens) > 2 else []
|
|
|
|
positional: List[str] = []
|
|
kw: Dict[str, str] = {}
|
|
for token in remaining:
|
|
if "=" in token:
|
|
k, _, v = token.partition("=")
|
|
kw[k.lower().strip()] = v.strip()
|
|
else:
|
|
positional.append(token)
|
|
|
|
return cls(
|
|
verb=verb,
|
|
subcommand=subcommand,
|
|
args=tuple(positional),
|
|
kwargs=kw,
|
|
raw=raw,
|
|
is_known=verb in KNOWN_VERBS,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ControlConfig:
|
|
"""
|
|
Parsed operator access policy for the control channel.
|
|
|
|
operator_allowlist: Frozenset of Matrix user IDs allowed to issue commands.
|
|
control_rooms: Frozenset of room IDs designated as control channels.
|
|
"""
|
|
operator_allowlist: FrozenSet[str] = field(default_factory=frozenset)
|
|
control_rooms: FrozenSet[str] = field(default_factory=frozenset)
|
|
|
|
@property
|
|
def is_enabled(self) -> bool:
|
|
"""Control channel is effective only when both sets are non-empty."""
|
|
return bool(self.operator_allowlist and self.control_rooms)
|
|
|
|
|
|
# ── Parsers ────────────────────────────────────────────────────────────────────
|
|
|
|
def parse_control_config(
|
|
raw_allowlist: str,
|
|
raw_control_rooms: str,
|
|
) -> ControlConfig:
|
|
"""
|
|
Parse BRIDGE_OPERATOR_ALLOWLIST and BRIDGE_CONTROL_ROOMS.
|
|
|
|
Allowlist format: "@ivan:daarion.space,@sergiy:daarion.space"
|
|
Control rooms fmt: "!opsroom:server,!opsroom2:server2"
|
|
|
|
Raises ValueError on:
|
|
- Malformed Matrix user ID
|
|
- Malformed room ID
|
|
"""
|
|
operators: List[str] = []
|
|
errors: List[str] = []
|
|
|
|
for entry in raw_allowlist.split(","):
|
|
uid = entry.strip()
|
|
if not uid:
|
|
continue
|
|
if not _MATRIX_USER_RE.match(uid):
|
|
errors.append(f"Invalid operator user_id: {uid!r}")
|
|
else:
|
|
operators.append(uid)
|
|
|
|
rooms: List[str] = []
|
|
for entry in raw_control_rooms.split(","):
|
|
rid = entry.strip()
|
|
if not rid:
|
|
continue
|
|
if not _ROOM_ID_RE.match(rid):
|
|
errors.append(f"Invalid control room_id: {rid!r}")
|
|
else:
|
|
rooms.append(rid)
|
|
|
|
if errors:
|
|
raise ValueError(f"Control config parse errors: {'; '.join(errors)}")
|
|
|
|
cfg = ControlConfig(
|
|
operator_allowlist=frozenset(operators),
|
|
control_rooms=frozenset(rooms),
|
|
)
|
|
if cfg.is_enabled:
|
|
logger.info(
|
|
"Control channel enabled: %d operators, %d rooms",
|
|
len(operators), len(rooms),
|
|
)
|
|
else:
|
|
logger.info("Control channel disabled (empty allowlist or no control rooms)")
|
|
return cfg
|
|
|
|
|
|
# ── Message inspection ────────────────────────────────────────────────────────
|
|
|
|
def is_control_message(text: str) -> bool:
|
|
"""Returns True if message looks like a control command (starts with '!')."""
|
|
return bool(text and text.strip().startswith("!"))
|
|
|
|
|
|
def is_control_room(room_id: str, config: ControlConfig) -> bool:
|
|
return room_id in config.control_rooms
|
|
|
|
|
|
def is_operator(sender: str, config: ControlConfig) -> bool:
|
|
return sender in config.operator_allowlist
|
|
|
|
|
|
def parse_command(text: str) -> Optional[ControlCommand]:
|
|
"""
|
|
Parse a control message into a ControlCommand.
|
|
Returns None if text is not a control command or is malformed/too long.
|
|
"""
|
|
stripped = text.strip()
|
|
if not stripped.startswith("!"):
|
|
return None
|
|
if len(stripped) > _MAX_CMD_LEN:
|
|
logger.warning("Control command too long (%d chars) — rejected", len(stripped))
|
|
return None
|
|
|
|
# Strip leading '!'
|
|
body = stripped[1:]
|
|
tokens = body.split()
|
|
if not tokens:
|
|
return None
|
|
if len(tokens) > _MAX_CMD_TOKENS:
|
|
logger.warning("Control command has too many tokens (%d) — rejected", len(tokens))
|
|
return None
|
|
|
|
return ControlCommand.from_tokens(tokens, raw=stripped)
|
|
|
|
|
|
# ── Authorization check ───────────────────────────────────────────────────────
|
|
|
|
def check_authorization(
|
|
sender: str,
|
|
room_id: str,
|
|
config: ControlConfig,
|
|
) -> Tuple[bool, str]:
|
|
"""
|
|
Returns (authorized: bool, rejection_reason: str).
|
|
|
|
Reasons:
|
|
- "not_operator": sender not in allowlist
|
|
- "not_control_room": room not in control_rooms
|
|
- "ok": authorized
|
|
"""
|
|
if not is_control_room(room_id, config):
|
|
return False, "not_control_room"
|
|
if not is_operator(sender, config):
|
|
logger.warning(
|
|
"Unauthorized control attempt: sender=%s room=%s not in allowlist",
|
|
sender, room_id,
|
|
)
|
|
return False, "not_operator"
|
|
return True, "ok"
|
|
|
|
|
|
# ── Reply helpers ─────────────────────────────────────────────────────────────
|
|
|
|
def not_implemented_reply(cmd: ControlCommand) -> str:
|
|
"""Reply for known commands not yet implemented (M3.0 stub)."""
|
|
return (
|
|
f"✅ Command acknowledged: `{cmd.raw}`\n"
|
|
f"⏳ `!{cmd.verb} {cmd.subcommand}` — implementation pending (M3.1+)."
|
|
)
|
|
|
|
|
|
def unknown_command_reply(cmd: ControlCommand) -> str:
|
|
"""Reply for unrecognised verbs."""
|
|
return (
|
|
f"⚠️ Unknown command: `{cmd.raw}`\n"
|
|
f"Known verbs: {', '.join(sorted(KNOWN_VERBS))}.\n"
|
|
f"Type `!help` for usage."
|
|
)
|
|
|
|
|
|
def unauthorized_reply(reason: str) -> str:
|
|
"""Reply for unauthorized command attempts (sent only when behavior=reply_error)."""
|
|
if reason == "not_operator":
|
|
return "⛔ Not authorised: your Matrix ID is not in the operator allowlist."
|
|
return "⛔ Not authorised: this room is not a control channel."
|
|
|
|
|
|
def help_reply() -> str:
|
|
"""Brief help text."""
|
|
return (
|
|
"**DAGI Bridge — Control Commands**\n\n"
|
|
"`!runbook start <path> [node=NODA1]` — Start a runbook run\n"
|
|
"`!runbook next <run_id>` — Advance to next step\n"
|
|
"`!runbook complete <run_id> step=<n> status=ok` — Mark step complete\n"
|
|
"`!runbook evidence <run_id>` — Get evidence artifact path\n"
|
|
"`!runbook status <run_id>` — Show current run state\n"
|
|
"`!status` — Bridge health summary\n"
|
|
"`!help` — This message\n\n"
|
|
"_Only authorised operators can issue control commands._"
|
|
)
|