"""Role-based CLI gating. MAINTAINERS: when you add a new Typer command (or add_typer group) that is master-only, register its name in MASTER_ONLY_COMMANDS / MASTER_ONLY_GROUPS below. The gate is the only thing that: (a) hides the command from `decnet --help` on worker hosts, and (b) prevents a misconfigured worker from invoking master-side logic. Forgetting to register a new command is a role-boundary bug. Grep for MASTER_ONLY when touching command registration. Worker-legitimate commands (NOT in these sets): agent, updater, forwarder, status, collect, probe, sniffer. Agents run deckies locally and should be able to inspect them + run the per-host microservices (collector streams container logs, prober characterizes attackers hitting this host, sniffer captures traffic). Mutator and Profiler stay master-only: the mutator orchestrates respawns across the swarm; the profiler rebuilds attacker profiles against the master DB (no per-host DB exists). """ from __future__ import annotations import os import typer from .utils import console MASTER_ONLY_COMMANDS: frozenset[str] = frozenset({ "api", "swarmctl", "deploy", "redeploy", "teardown", "mutate", "listener", "profiler", "services", "distros", "correlate", "archetypes", "web", "db-reset", }) MASTER_ONLY_GROUPS: frozenset[str] = frozenset({"swarm"}) def _agent_mode_active() -> bool: """True when the host is configured as an agent AND master commands are disallowed (the default for agents). Workers overriding this explicitly set DECNET_DISALLOW_MASTER=false to opt into hybrid use.""" mode = os.environ.get("DECNET_MODE", "master").lower() disallow = os.environ.get("DECNET_DISALLOW_MASTER", "true").lower() == "true" return mode == "agent" and disallow def _require_master_mode(command_name: str) -> None: """Defence-in-depth: called at the top of every master-only command body. The registration-time gate in _gate_commands_by_mode() already hides these commands from Typer's dispatch table, but this check protects against direct function imports (e.g. from tests or third-party tools) that would bypass Typer entirely.""" if _agent_mode_active(): console.print( f"[red]`decnet {command_name}` is a master-only command; this host " f"is configured as an agent (DECNET_MODE=agent).[/]" ) raise typer.Exit(1) def _gate_commands_by_mode(_app: typer.Typer) -> None: if not _agent_mode_active(): return _app.registered_commands = [ c for c in _app.registered_commands if (c.name or c.callback.__name__) not in MASTER_ONLY_COMMANDS ] _app.registered_groups = [ g for g in _app.registered_groups if g.name not in MASTER_ONLY_GROUPS ]