""" 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 Any, Dict, FrozenSet, List, Optional, Tuple logger = logging.getLogger(__name__) # ── Constants ───────────────────────────────────────────────────────────────── # Supported control verbs VERB_RUNBOOK = "runbook" VERB_STATUS = "status" VERB_NODES = "nodes" # M5.1: node policy overview VERB_NODE = "node" # M6.0: dynamic room-node override commands VERB_ROOM = "room" # M6.1: dynamic mixed room agent overrides VERB_POLICY = "policy" # M6.2: policy snapshot export/import VERB_CONFIRM = "confirm" # M9.0: two-step confirmation for dangerous commands VERB_HELP = "help" KNOWN_VERBS: FrozenSet[str] = frozenset({ VERB_RUNBOOK, VERB_STATUS, VERB_NODES, VERB_NODE, VERB_ROOM, VERB_POLICY, VERB_CONFIRM, VERB_HELP, }) # ── M9.0: Dangerous command detection ───────────────────────────────────────── def is_dangerous_cmd(cmd: "ControlCommand") -> bool: """ Return True if the command requires two-step confirmation before applying. Dangerous verbs: !node set room=... node=... — changes room routing !room agents set room=... agents=... — replaces all agents for a room !policy import ... — overwrites policy DB (both modes) """ v = cmd.verb sub = (cmd.subcommand or "").strip().lower() if v == VERB_NODE and sub == "set": return True if v == VERB_ROOM and sub == "agents" and cmd.args and cmd.args[0].lower() == "set": return True if v == VERB_POLICY and sub == "import": return True # M10.0: prune_exports is dangerous only when dry_run=0 (actual deletion) if v == VERB_POLICY and sub == "prune_exports": dry_raw = cmd.kwargs.get("dry_run", "1").strip() is_dry = dry_raw not in ("0", "false", "no") return not is_dry # M10.1: restore is always dangerous (no dry_run option) if v == VERB_POLICY and sub == "restore": return True return False def build_normalized_args(cmd: "ControlCommand") -> str: """ Build a human-readable normalized representation of the command args. Used in audit events and confirmation prompts. """ parts: list[str] = [] # For !room agents set, skip the "set" positional from args display skip_first_arg = cmd.verb == VERB_ROOM and cmd.subcommand == "agents" for i, a in enumerate(cmd.args): if skip_first_arg and i == 0: continue parts.append(a) for k, v in sorted(cmd.kwargs.items()): parts.append(f"{k}={v}") return " ".join(parts) def confirm_intent_reply(action_summary: str, nonce: str, ttl_s: int) -> str: """Reply when a dangerous command is held pending confirmation (M9.0).""" return ( f"⚠️ **Confirm required**\n" f"Action: `{action_summary}`\n" f"Type `!confirm {nonce}` within {ttl_s}s to apply.\n" f"_(Only you can confirm this action.)_" ) def confirm_success_reply(action_result: str) -> str: """Reply when a confirmation is accepted and the action applied (M9.0).""" return f"✅ Confirmed and applied.\n{action_result}" def confirm_expired_reply() -> str: """Reply when the nonce is invalid, expired, or from a different sender (M9.0).""" return ( "❌ Invalid or expired confirmation code. " "The action was **not** applied.\n" "Re-issue the original command to get a new code." ) # M6.1: !room subcommand + actions ROOM_SUBCMD_AGENTS = "agents" ROOM_ACTION_SET = "set" ROOM_ACTION_ADD = "add" ROOM_ACTION_REMOVE = "remove" ROOM_ACTION_GET = "get" ROOM_ACTION_LIST = "list" ROOM_ACTION_UNSET = "unset" # remove full override _VALID_ROOM_ACTIONS = frozenset({ ROOM_ACTION_SET, ROOM_ACTION_ADD, ROOM_ACTION_REMOVE, ROOM_ACTION_GET, ROOM_ACTION_LIST, ROOM_ACTION_UNSET, }) # M6.0: !node subcommands NODE_SUBCMD_SET = "set" NODE_SUBCMD_UNSET = "unset" NODE_SUBCMD_GET = "get" NODE_SUBCMD_LIST = "list" _VALID_NODE_SUBCMDS = frozenset({NODE_SUBCMD_SET, NODE_SUBCMD_UNSET, NODE_SUBCMD_GET, NODE_SUBCMD_LIST}) # Runbook subcommands (M3.x) SUBCOMMAND_START = "start" # M3.1 — implemented SUBCOMMAND_NEXT = "next" # M3.2 — implemented SUBCOMMAND_COMPLETE = "complete" # M3.2 — implemented SUBCOMMAND_EVIDENCE = "evidence" # M3.3 — implemented SUBCOMMAND_STATUS = "status" # M3.3 — implemented SUBCOMMAND_POST_REVIEW = "post_review" # M3.3 — implemented # 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.""" return ( f"✅ Command acknowledged: `{cmd.raw}`\n" f"⏳ `!{cmd.verb} {cmd.subcommand}` — implementation pending." ) def next_usage_reply() -> str: """Reply when !runbook next is called without a run_id.""" return ( "⚠️ Usage: `!runbook next `\n" "Example: `!runbook next abc-123`" ) def complete_usage_reply() -> str: """Reply when !runbook complete is missing required args.""" return ( "⚠️ Usage: `!runbook complete step= status=ok|warn|fail [notes=...]`\n" "Example: `!runbook complete abc-123 step=3 status=ok notes=done`\n" "Notes with spaces: join without quotes — `notes=done_and_verified`." ) def start_usage_reply() -> str: """Reply when !runbook start is called with missing/invalid runbook_path.""" return ( "⚠️ Usage: `!runbook start [node=NODA1]`\n" "Example: `!runbook start runbooks/rehearsal-v1-checklist.md node=NODA1`\n" "runbook_path must be a relative path without `..`." ) def runbook_started_reply(run_id: str, steps_total: int, status: str) -> str: """Success reply after sofiia-console creates a runbook run.""" return ( f"✅ runbook started: `run_id={run_id}` steps={steps_total} status={status}\n" f"Next: `!runbook next {run_id}`" ) def runbook_start_error_reply(reason: str) -> str: """Error reply when sofiia-console returns a non-2xx or connection error.""" return f"❌ failed to start runbook: {reason}" # ── M3.2 reply helpers ──────────────────────────────────────────────────────── # Max chars of instructions_md to include in Matrix message before truncating _INSTRUCTIONS_EXCERPT_MAX = 1500 def next_manual_reply( run_id: str, step_index: int, steps_total: Optional[int], title: str, instructions_md: str, ) -> str: """Reply for a manual step returned by !runbook next.""" step_label = f"Step {step_index + 1}" if steps_total: step_label += f"/{steps_total}" excerpt = instructions_md.strip() truncated = False if len(excerpt) > _INSTRUCTIONS_EXCERPT_MAX: excerpt = excerpt[:_INSTRUCTIONS_EXCERPT_MAX].rsplit("\n", 1)[0] truncated = True parts = [ f"🧭 {step_label}: **{title}**", "", excerpt, ] if truncated: parts.append("_...(truncated — open in console for full instructions)_") parts += [ "", f"Complete: `!runbook complete {run_id} step={step_index} status=ok`", ] return "\n".join(parts) def next_auto_reply( run_id: str, step_index: int, action_type: str, step_status: str, duration_ms: Optional[int], completed: bool, ) -> str: """Reply for an auto step (http_check/script) completed by !runbook next.""" emoji = {"ok": "✅", "warn": "⚠️", "fail": "❌"}.get(step_status, "ℹ️") dur = f" duration={duration_ms}ms" if duration_ms is not None else "" header = f"{emoji} step {step_index + 1} ({action_type}) {step_status}{dur}" if completed: return ( f"{header}\n" "🎉 All steps completed!\n" f"Get evidence: `!runbook evidence {run_id}`" ) return f"{header}\nNext: `!runbook next {run_id}`" def next_error_reply(run_id: str, reason: str) -> str: """Error reply when !runbook next fails.""" return f"❌ failed to advance runbook: {reason}" def complete_ok_reply(run_id: str, step_index: int, status: str, run_completed: bool) -> str: """Success reply after !runbook complete.""" emoji = {"ok": "✅", "warn": "⚠️", "fail": "❌", "skipped": "⏭️"}.get(status, "✅") line1 = f"{emoji} recorded step {step_index + 1}: {status}" if run_completed: return f"{line1}\n🎉 All steps completed!\nGet evidence: `!runbook evidence {run_id}`" return f"{line1}\nNext: `!runbook next {run_id}`" def complete_error_reply(run_id: str, reason: str) -> str: """Error reply when !runbook complete fails.""" return f"❌ failed to complete step: {reason}" # ── M3.3 reply helpers ──────────────────────────────────────────────────────── def status_usage_reply() -> str: return ( "⚠️ Usage: `!runbook status `\n" "Example: `!runbook status abc-123`" ) def evidence_usage_reply() -> str: return ( "⚠️ Usage: `!runbook evidence `\n" "Example: `!runbook evidence abc-123`" ) def post_review_usage_reply() -> str: return ( "⚠️ Usage: `!runbook post_review `\n" "Example: `!runbook post_review abc-123`" ) def status_reply(run: dict) -> str: """Format !runbook status reply from a get_run response.""" run_id = run.get("run_id", "?") status = run.get("status", "?") current = run.get("current_step", 0) steps_total = run.get("steps_total") or len(run.get("steps", [])) runbook_path = run.get("runbook_path", "?") node_id = run.get("node_id", "?") evidence_path = run.get("evidence_path") # Count warn/fail steps steps = run.get("steps", []) warn_count = sum(1 for s in steps if s.get("status") == "warn") fail_count = sum(1 for s in steps if s.get("status") == "fail") status_emoji = { "running": "🔄", "completed": "✅", "aborted": "🛑", "paused": "⏸️", }.get(status, "ℹ️") step_label = f"{current}/{steps_total}" if steps_total else str(current) lines = [ f"{status_emoji} `run_id={run_id}` status={status} step={step_label}", f"runbook: `{runbook_path}` node: {node_id}", ] if warn_count or fail_count: lines.append(f"warn={warn_count} fail={fail_count}") if evidence_path: lines.append(f"evidence: `{evidence_path}`") if status == "completed" and not evidence_path: lines.append(f"Get evidence: `!runbook evidence {run_id}`") elif status == "completed" and evidence_path: lines.append(f"Post-review: `!runbook post_review {run_id}`") return "\n".join(lines) def status_error_reply(run_id: str, reason: str) -> str: return f"❌ failed to get status: {reason}" def evidence_reply(result: dict) -> str: """Success reply after !runbook evidence.""" path = result.get("evidence_path", "?") size = result.get("bytes", 0) run_id = result.get("run_id", "") ts = result.get("created_at", "") lines = [f"📄 evidence created: `{path}` (bytes={size})"] if ts: lines.append(f"created_at: {ts}") if run_id: lines.append(f"Next: `!runbook post_review {run_id}`") return "\n".join(lines) def evidence_error_reply(run_id: str, reason: str) -> str: return f"❌ failed to generate evidence: {reason}" def post_review_reply(result: dict) -> str: """Success reply after !runbook post_review.""" path = result.get("path", "?") size = result.get("bytes", 0) ts = result.get("created_at", "") lines = [f"🧾 post-review created: `{path}` (bytes={size})"] if ts: lines.append(f"created_at: {ts}") return "\n".join(lines) def post_review_error_reply(run_id: str, reason: str) -> str: return f"❌ failed to generate post-review: {reason}" # ── M3.4 safety helpers ─────────────────────────────────────────────────────── #: Maximum length of notes/free-text operator input accepted before truncation. MAX_NOTES_LEN: int = 500 #: Control characters (U+0000–U+001F minus tab/newline) that must be stripped. _CTRL_CHARS = "".join(chr(i) for i in range(32) if i not in (9, 10, 13)) def sanitize_notes(notes: str) -> str: """ Strip control characters and truncate notes to MAX_NOTES_LEN. Safe to call with any string; returns empty string for falsy input. """ if not notes: return "" cleaned = notes.translate(str.maketrans("", "", _CTRL_CHARS)) if len(cleaned) > MAX_NOTES_LEN: cleaned = cleaned[:MAX_NOTES_LEN] + "…" return cleaned def rate_limited_reply(scope: str, retry_after_s: float) -> str: """Reply when a control command is rejected by rate limiter or cooldown.""" secs = f"{retry_after_s:.0f}s" if retry_after_s >= 1 else "a moment" return f"⏳ rate limited ({scope}), retry after {secs}" def status_not_available_reply() -> str: return "⚠️ Bridge status not available (service initialising or config missing)." # M5.1: !nodes reply _MAX_ROOM_OVERRIDES_SHOWN = 10 def nodes_reply( policy_info: dict, node_stats: Optional[dict] = None, sticky_info: Optional[dict] = None, ) -> str: """ Compact reply for `!nodes` in control room. policy_info: from NodePolicy.as_info_dict() node_stats: optional dict {node_id: {"routed": N, "rejected": M, "health": ..., ...}} sticky_info: optional dict from StickyNodeCache (M8.1) """ default = policy_info.get("default_node", "?") allowed = sorted(policy_info.get("allowed_nodes") or []) overrides = policy_info.get("room_overrides", {}) or {} allowed_str = ", ".join(f"`{n}`" for n in allowed) lines = [ "🌐 **Node policy**", f"Default: `{default}` Allowed: {allowed_str}", ] if isinstance(overrides, dict) and overrides: lines.append(f"\n**Room overrides** ({len(overrides)}):") items = list(overrides.items())[:_MAX_ROOM_OVERRIDES_SHOWN] for room_id, node in items: lines.append(f" `{room_id}` → `{node}`") if len(overrides) > _MAX_ROOM_OVERRIDES_SHOWN: lines.append(f" _(+{len(overrides) - _MAX_ROOM_OVERRIDES_SHOWN} more)_") elif isinstance(overrides, int): # as_info_dict returns room_overrides as int count, not dict if overrides: lines.append(f"\nRoom overrides: {overrides}") else: lines.append("\nNo room overrides configured.") else: lines.append("\nNo room overrides configured.") if node_stats: lines.append("\n**Per-node stats** (since last restart):") for node_id in sorted(node_stats): ns = node_stats[node_id] routed = ns.get("routed", 0) rejected = ns.get("rejected", 0) health = ns.get("health", "") ewma = ns.get("ewma_latency_s") consec = ns.get("consecutive_failures", 0) stat_parts = [f"routed={routed}", f"rejected={rejected}"] if health: stat_parts.append(f"health={health}") if ewma is not None: stat_parts.append(f"ewma={ewma:.2f}s") if consec: stat_parts.append(f"consec_fail={consec}") lines.append(f" `{node_id}`: " + " ".join(stat_parts)) # M8.1: sticky cache section if sticky_info is not None: active = sticky_info.get("active_keys", 0) ttl = sticky_info.get("ttl_s", 0) if active: lines.append(f"\n**Sticky routing** (anti-flap): {active} active ttl={ttl:.0f}s") for entry in sticky_info.get("entries", []): rem = entry.get("remaining_s", 0) lines.append( f" `{entry['key']}` → `{entry['node']}` ({rem:.0f}s left)" ) if sticky_info.get("truncated"): lines.append(f" _(+{sticky_info['truncated']} more)_") else: lines.append(f"\nSticky routing: none active ttl={ttl:.0f}s") return "\n".join(lines) # ── M6.0: !node subcommand parser + reply helpers ────────────────────────────── import re as _re _ROOM_KWARG_RE = _re.compile(r"\broom=(\S+)", _re.IGNORECASE) _NODE_VAL_RE = _re.compile(r"\bnode=(\w+)", _re.IGNORECASE) _ROOM_ID_RE = _re.compile(r"^![a-zA-Z0-9._\-]+:[a-zA-Z0-9._\-]+$") def parse_node_cmd(args_text: str) -> Tuple[str, Optional[str], Optional[str]]: """ Parse `!node [room=...] [node=...]` arguments. Returns (subcmd, room_id_or_None, node_id_or_None). subcmd is lower-cased; node_id is upper-cased. """ parts = args_text.strip().split(None, 1) if not parts: return ("", None, None) subcmd = parts[0].lower() rest = parts[1] if len(parts) > 1 else "" room_m = _ROOM_KWARG_RE.search(rest) node_m = _NODE_VAL_RE.search(rest) room_id = room_m.group(1) if room_m else None node_id = node_m.group(1).upper() if node_m else None return (subcmd, room_id, node_id) def node_cmd_validate_room(room_id: str) -> bool: """Return True if room_id matches basic Matrix room ID format.""" return bool(_ROOM_ID_RE.match(room_id)) if room_id else False def node_cmd_reply_set(room_id: str, node_id: str) -> str: return f"✅ Override set: `{room_id}` → `{node_id}`" def node_cmd_reply_unset_ok(room_id: str) -> str: return f"✅ Override removed for `{room_id}`" def node_cmd_reply_unset_not_found(room_id: str) -> str: return f"ℹ️ No override was set for `{room_id}`" def node_cmd_reply_get( room_id: str, node_id: Optional[str], env_node: Optional[str], default_node: str, ) -> str: lines = [f"📌 **Node info for** `{room_id}`"] if node_id: lines.append(f"Dynamic override: `{node_id}` _(set by operator)_") else: lines.append("Dynamic override: _none_") if env_node: lines.append(f"Env map: `{env_node}`") lines.append(f"Default: `{default_node}`") effective = node_id or env_node or default_node lines.append(f"\nEffective node: **`{effective}`**") return "\n".join(lines) def node_cmd_reply_list( overrides: List[Tuple[str, str, int]], total: int, ) -> str: import datetime lines = [f"📋 **Dynamic node overrides** ({total} total)"] if not overrides: lines.append("_None set._") else: for room_id, node_id, updated_at in overrides: ts = datetime.datetime.utcfromtimestamp(updated_at).strftime("%Y-%m-%d %H:%M") lines.append(f" `{room_id}` → `{node_id}` _(at {ts} UTC)_") if total > len(overrides): lines.append(f" _(+{total - len(overrides)} more)_") return "\n".join(lines) def node_cmd_reply_error(msg: str) -> str: return ( f"❌ {msg}\n\n" "Usage:\n" " `!node set room=!room:server node=NODA2`\n" " `!node unset room=!room:server`\n" " `!node get room=!room:server`\n" " `!node list`" ) # ── M6.1: !room agents reply helpers ────────────────────────────────────────── _AGENTS_KWARG_RE = _re.compile(r"\bagents=(\S+)", _re.IGNORECASE) _AGENT_KWARG_RE = _re.compile(r"\bagent=(\w+)", _re.IGNORECASE) _DEFAULT_KWARG_RE = _re.compile(r"\bdefault=(\w+)", _re.IGNORECASE) def parse_room_agents_cmd( subcommand: str, args: tuple, kwargs: Dict[str, str], ) -> Tuple[str, Optional[str], Optional[List[str]], Optional[str], Optional[str]]: """ Parse !room agents [room=...] [agents=...] [agent=...] [default=...] args. Returns (action, room_id, agents_or_None, single_agent_or_None, default_agent_or_None). action: the ROOM_ACTION_* constant (from args[0] or subcommand) room_id: from kwargs["room"] agents: from kwargs["agents"] as a list (for set command) single_agent: from kwargs["agent"] (for add/remove) default_agent: from kwargs["default"] """ # action is args[0] when subcommand == "agents" action = (args[0].lower() if args else "").strip() or subcommand.lower() room_id = kwargs.get("room") # agents= may be comma-separated raw_agents = kwargs.get("agents", "") agents: Optional[List[str]] = ( [a.strip().lower() for a in raw_agents.split(",") if a.strip()] if raw_agents else None ) single_agent = kwargs.get("agent", "").strip().lower() or None default_agent = kwargs.get("default", "").strip().lower() or None return action, room_id, agents, single_agent, default_agent def room_agents_reply_set(room_id: str, agents: List[str], default_agent: str) -> str: agents_str = ", ".join(f"`{a}`" for a in sorted(agents)) return ( f"✅ Agent override set for `{room_id}`\n" f"Agents: {agents_str}\n" f"Default: `{default_agent}`" ) def room_agents_reply_add(room_id: str, agent: str, agents: List[str], default_agent: Optional[str]) -> str: agents_str = ", ".join(f"`{a}`" for a in sorted(agents)) return ( f"✅ Agent `{agent}` added to `{room_id}`\n" f"Current agents: {agents_str}" + (f"\nDefault: `{default_agent}`" if default_agent else "") ) def room_agents_reply_remove(room_id: str, agent: str, agents: List[str], default_agent: Optional[str]) -> str: if agents: agents_str = ", ".join(f"`{a}`" for a in sorted(agents)) return ( f"✅ Agent `{agent}` removed from `{room_id}`\n" f"Remaining: {agents_str}" + (f"\nDefault: `{default_agent}`" if default_agent else "") ) return f"✅ Agent `{agent}` removed — no agents left, override cleared for `{room_id}`" def room_agents_reply_unset_ok(room_id: str) -> str: return f"✅ Agent override cleared for `{room_id}` (using env/default config)" def room_agents_reply_unset_not_found(room_id: str) -> str: return f"ℹ️ No agent override was set for `{room_id}`" def room_agents_reply_get( room_id: str, override_agents: Optional[List[str]], override_default: Optional[str], env_agents: Optional[List[str]], env_default: Optional[str], ) -> str: lines = [f"📌 **Agent policy for** `{room_id}`"] if override_agents: agents_str = ", ".join(f"`{a}`" for a in sorted(override_agents)) lines.append(f"Dynamic override: {agents_str} default=`{override_default or '?'}`") else: lines.append("Dynamic override: _none_") if env_agents: env_str = ", ".join(f"`{a}`" for a in sorted(env_agents)) lines.append(f"Env config: {env_str} default=`{env_default or '?'}`") else: lines.append("Env config: _not configured_") effective_agents = override_agents or env_agents or [] effective_default = override_default or env_default or "?" lines.append(f"\nEffective agents: **{', '.join(f'`{a}`' for a in sorted(effective_agents))}** default=**`{effective_default}`**") return "\n".join(lines) def room_agents_reply_list( overrides: List[Tuple[str, List[str], Optional[str], int]], total: int, ) -> str: import datetime lines = [f"📋 **Dynamic agent overrides** ({total} total)"] if not overrides: lines.append("_None set._") else: for room_id, agents, default_agent, updated_at in overrides: ts = datetime.datetime.utcfromtimestamp(updated_at).strftime("%Y-%m-%d %H:%M") agents_str = ", ".join(agents) lines.append(f" `{room_id}`: [{agents_str}] default=`{default_agent or '?'}` _(at {ts} UTC)_") if total > len(overrides): lines.append(f" _(+{total - len(overrides)} more)_") return "\n".join(lines) def room_agents_reply_error(msg: str) -> str: return ( f"❌ {msg}\n\n" "Usage:\n" " `!room agents set room=!X agents=sofiia,helion [default=sofiia]`\n" " `!room agents add room=!X agent=druid`\n" " `!room agents remove room=!X agent=helion`\n" " `!room agents get room=!X`\n" " `!room agents unset room=!X`\n" " `!room agents list`" ) # ── M6.2: !policy export/import reply helpers + path validator ──────────────── import os as _os import json as _json POLICY_EXPORTS_SUBDIR = "policy_exports" def validate_export_path(exports_dir: str, filename: str) -> Optional[str]: """ Validate and resolve an export filename to an absolute path. Security: only allow simple filenames (no slashes, no `..`). Returns the absolute safe path, or None if invalid. """ if not filename: return None # Reject anything with directory separators or traversal sequences if "/" in filename or "\\" in filename or ".." in filename: return None # Only allow safe characters: alphanumeric, dash, underscore, dot if not _re.match(r"^[a-zA-Z0-9._\-]+$", filename): return None full_path = _os.path.join(exports_dir, filename) try: resolved = _os.path.realpath(full_path) exports_resolved = _os.path.realpath(exports_dir) if not resolved.startswith(exports_resolved + _os.sep): return None except Exception: # noqa: BLE001 return None return full_path def policy_export_reply(path: str, node_count: int, agent_count: int) -> str: filename = _os.path.basename(path) return ( f"✅ **Policy exported**\n" f"File: `{filename}`\n" f"Node overrides: {node_count} Agent overrides: {agent_count}" ) def policy_import_dry_run_reply(stats: dict, mode: str) -> str: return ( f"🔍 **Import dry-run** (mode=`{mode}`, no changes applied)\n" f"Node overrides: +{stats.get('node_added',0)} ~{stats.get('node_updated',0)} -{stats.get('node_deleted',0)}\n" f"Agent overrides: +{stats.get('agent_added',0)} ~{stats.get('agent_updated',0)} -{stats.get('agent_deleted',0)}\n" f"_Use `dry_run=0` to apply._" ) def format_import_diff(diff: Any) -> str: """ Format an ImportDiff as a human-readable Markdown string (M9.1). `diff` is an ImportDiff instance from policy_store. """ lines: List[str] = [] # Node overrides row node_parts: List[str] = [] if diff.node_added: node_parts.append(f"+{diff.node_added} added") if diff.node_updated: node_parts.append(f"~{diff.node_updated} updated") if diff.node_deleted: node_parts.append(f"-{diff.node_deleted} deleted ⚠️") lines.append("**Node overrides:** " + (", ".join(node_parts) if node_parts else "no changes")) # Agent overrides row agent_parts: List[str] = [] if diff.agent_added: agent_parts.append(f"+{diff.agent_added} added") if diff.agent_updated: agent_parts.append(f"~{diff.agent_updated} updated") if diff.agent_deleted: agent_parts.append(f"-{diff.agent_deleted} deleted ⚠️") lines.append("**Agent overrides:** " + (", ".join(agent_parts) if agent_parts else "no changes")) # Sample affected rooms if getattr(diff, "sample_keys", None): keys_str = ", ".join(f"`{k}`" for k in diff.sample_keys) more = diff.total_changes() - len(diff.sample_keys) suffix = f" _(+{more} more)_" if more > 0 else "" lines.append(f"**Affected rooms:** {keys_str}{suffix}") # Replace danger banner if getattr(diff, "is_replace", False): lines.append("⚠️ **REPLACE mode** — existing overrides NOT in the file will be **deleted**.") return "\n".join(lines) def policy_import_intent_reply( diff: Any, action_summary: str, nonce: str, ttl_s: int, ) -> str: """Reply for !policy import intent with diff preview (M9.1).""" lines = [ "⚠️ **Confirm required**", f"Action: `{action_summary}`", "", "**Preview:**", format_import_diff(diff), "", ] if diff.total_changes() == 0: lines.append("_(No policy changes would be made.)_") lines.append("") lines += [ f"Type `!confirm {nonce}` within {ttl_s}s to apply.", "_(Only you can confirm. If the file changes, this confirm will be rejected.)_", ] return "\n".join(lines) def policy_import_reply(stats: dict, mode: str) -> str: return ( f"✅ **Policy imported** (mode=`{mode}`)\n" f"Node overrides: +{stats.get('node_added',0)} ~{stats.get('node_updated',0)} -{stats.get('node_deleted',0)}\n" f"Agent overrides: +{stats.get('agent_added',0)} ~{stats.get('agent_updated',0)} -{stats.get('agent_deleted',0)}" ) def policy_restore_intent_reply( diff: Any, action_summary: str, nonce: str, ttl_s: int, ) -> str: """Reply for !policy restore intent — rollback preview + confirm prompt (M10.1).""" diff_text = format_import_diff(diff) return ( f"🔄 **Policy restore (rollback) preview**\n" f"{diff_text}\n\n" f"⚠️ **Rollback action:** `{action_summary}`\n\n" f"Type `!confirm {nonce}` to apply restore (expires in {ttl_s}s)" ) def policy_restore_applied_reply( stats: Any, mode: str, autobackup_basename: str = "", ) -> str: """Reply after !policy restore is confirmed and applied (M10.1).""" n_a = stats.get("node_added", 0) if isinstance(stats, dict) else 0 n_u = stats.get("node_updated", 0) if isinstance(stats, dict) else 0 n_d = stats.get("node_deleted", 0) if isinstance(stats, dict) else 0 a_a = stats.get("agent_added", 0) if isinstance(stats, dict) else 0 a_u = stats.get("agent_updated", 0) if isinstance(stats, dict) else 0 a_d = stats.get("agent_deleted", 0) if isinstance(stats, dict) else 0 backup_line = ( f"\n\n💾 Pre-restore backup saved: `{autobackup_basename}`" if autobackup_basename else "" ) return ( f"✅ **Policy restored** (mode={mode})\n" f"Node overrides: +{n_a} ~{n_u} -{n_d}\n" f"Agent overrides: +{a_a} ~{a_u} -{a_d}" f"{backup_line}" ) def policy_history_reply(changes: List[Any]) -> str: """ Format policy_changes records for !policy history reply (M10.2). Each line: #{n}. [id:NN] [YYYY-MM-DD HH:MM] verb/mode +Xn ~Yn -Zn `file` op:`hash8` [⚠️] Use !policy change id=NN to see full details. """ if not changes: return "📋 **Policy change history**\nNo policy changes recorded yet." lines = ["📋 **Policy change history** (most recent first)\n"] for i, c in enumerate(changes, 1): destr_flag = " ⚠️" if c.is_destructive else "" fname = c.source_file[:40] + "…" if len(c.source_file) > 40 else c.source_file line = ( f"{i}. [id:{c.id}] [{c.when_str()}] `{c.verb}/{c.mode}`" f" {c.changes_short()}{destr_flag}" f" `{fname}`" f" op:`{c.sender_hash[:8]}`" ) lines.append(line) lines.append("\nUse `!policy change id=` for full details of a specific change.") return "\n".join(lines) def policy_change_detail_reply(change: Any) -> str: """ Format full details of a single PolicyChange for !policy change id= (M10.3). """ destr_str = "⚠️ Yes" if change.is_destructive else "No" fname = change.source_file[:60] + "…" if len(change.source_file) > 60 else change.source_file lines = [ f"🔍 **Policy change #{change.id}**\n", f"**Verb:** `{change.verb}`", f"**Mode:** `{change.mode}`", f"**Applied:** {change.when_str()} UTC", f"**Operator:** op:`{change.sender_hash[:8]}`", f"**File:** `{fname}`", f"**Destructive:** {destr_str}", "", "**Changes:**", f" Nodes: +{change.node_added} added ~{change.node_updated} updated -{change.node_deleted} deleted", f" Agents: +{change.agent_added} added ~{change.agent_updated} updated -{change.agent_deleted} deleted", "", "**Summary:**", f" {change.diff_summary}", ] return "\n".join(lines) def policy_prune_preview_reply(result: Any, retention_days: int) -> str: """Reply for !policy prune_exports dry_run=1 — preview of what would be pruned (M10.0).""" if result.count == 0: return ( f"🗑️ **Policy exports prune preview** (retention={retention_days}d)\n" "No files older than the retention window found. Nothing to prune." ) samples = result.sample_filenames(5) sample_str = "\n".join(f" - `{f}`" for f in samples) more = result.count - len(samples) more_str = f"\n _(+{more} more)_" if more > 0 else "" size_kb = result.total_bytes // 1024 return ( f"🗑️ **Policy exports prune preview** (retention={retention_days}d)\n" f"Would delete **{result.count}** file(s) (~{size_kb} KB):\n" f"{sample_str}{more_str}\n\n" f"To actually prune: `!policy prune_exports dry_run=0`" ) def policy_prune_applied_reply(result: Any, retention_days: int) -> str: """Reply after !policy prune_exports dry_run=0 is confirmed and applied (M10.0).""" if result.count == 0: return ( f"🗑️ **Policy exports pruned** (retention={retention_days}d)\n" "No files matched the retention window." ) size_kb = result.total_bytes // 1024 return ( f"✅ **Policy exports pruned** (retention={retention_days}d)\n" f"Deleted **{result.count}** file(s) (~{size_kb} KB freed)." ) def policy_cmd_error(msg: str) -> str: return ( f"❌ {msg}\n\n" "Usage:\n" " `!policy export`\n" " `!policy import path=policy-YYYYMMDD-HHMMSS.json [mode=merge|replace] [dry_run=0]`" ) 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 [node=NODA1]` — Start a runbook run ✅\n" "`!runbook next ` — Advance to next step ✅\n" "`!runbook complete step= status=ok [notes=...]` — Mark step complete ✅\n" "`!runbook status ` — Show run status ✅\n" "`!runbook evidence ` — Generate release evidence ✅\n" "`!runbook post_review ` — Generate post-release review ✅\n" "`!status` — Bridge health summary ✅\n" "`!nodes` — Node policy overview ✅\n" "`!node set room=!room:server node=NODA2` — Set room-node override ✅\n" "`!node unset room=!room:server` — Remove room-node override ✅\n" "`!node get room=!room:server` — Show current override ✅\n" "`!node list` — List dynamic overrides (top 10) ✅\n" "`!room agents set room=!X agents=sofiia,helion [default=sofiia]` — Set agent list ✅\n" "`!room agents add room=!X agent=druid` — Add agent to room ✅\n" "`!room agents remove room=!X agent=helion` — Remove agent from room ✅\n" "`!room agents get room=!X` — Show current agent policy ✅\n" "`!room agents list` — List all rooms with agent overrides ✅\n" "`!room agents unset room=!X` — Remove all agent overrides for room ✅\n" "`!policy export` — Export policy snapshot to file ✅\n" "`!policy import path= [mode=merge|replace] [dry_run=0]` — Import policy snapshot ✅\n" "`!help` — This message\n\n" "_Only authorised operators can issue control commands._" )