Source code for tools.privilege_capsh

"""Stargazer capability shell — ``capsh`` for the privilege bitmask system.

Star calls this to get a full decoded view of a user's privilege state
across all scopes: global, guild, channel, effective (resolved), and
per-bit resolution mode annotations.

Equivalent to ``capsh --print`` on Linux capabilities.
"""

from __future__ import annotations

import json
import logging
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
    from tool_context import ToolContext

logger = logging.getLogger(__name__)

TOOL_NAME = "privilege_capsh"
TOOL_DESCRIPTION = (
    "Print a full decoded capability dump for a user's privilege bitmask, "
    "similar to Linux capsh --print. Shows the global mask, guild-scoped "
    "mask, channel-scoped mask, the effective (resolved) mask for the "
    "current context, and per-bit resolution mode annotations "
    "(NORMAL/INVERTED/DANGEROUS). Defaults to the user you are "
    "currently talking to. Reading another user requires ALTER_PRIVILEGES."
)
TOOL_PARAMETERS = {
    "type": "object",
    "properties": {
        "target_user_id": {
            "type": "string",
            "description": (
                "User ID to inspect. Omit to inspect the user you are "
                "currently talking to."
            ),
        },
        "guild_id": {
            "type": "string",
            "description": (
                "Guild ID to evaluate scoped privileges in. "
                "Omit to use the current guild context."
            ),
        },
        "channel_id": {
            "type": "string",
            "description": (
                "Channel ID to evaluate scoped privileges in. "
                "Omit to use the current channel context."
            ),
        },
    },
}


_MODE_LABELS = {0: "NORMAL", 1: "INVERTED", 2: "DANGEROUS"}


[docs] async def run( target_user_id: str = "", guild_id: str = "", channel_id: str = "", ctx: "ToolContext | None" = None, ) -> str: """Produce a full decoded capability dump of a user's privilege bitmask. Entry point for the ``privilege_capsh`` tool — the ``capsh --print`` analogue for Stargazer's 64-bit privilege system. It resolves the target user's global, guild, and channel masks, computes the effective (resolved) mask under each bit's resolution mode, annotates every set bit (NORMAL/INVERTED/DANGEROUS and whether a scope granted or revoked it), diffs global versus effective, and estimates a self-escalation ceiling (the maximum mask the user could reach if they went adversarial, given any admin bits they hold). All privilege logic is imported lazily from ``tools.alter_privileges`` (``PRIVILEGES``, ``DANGEROUS_BITS``, ``ALL_BITS``, ``_bit_mode``, ``_is_admin``, ``_mask_to_names``, ``_get_scoped_mask``, ``get_user_privileges``, ``resolve_privilege_bit``); the mask reads ultimately consult Redis through ``ctx.redis``. Guild/channel scopes default to ``ctx.guild_id`` / ``ctx.channel_id`` when not passed explicitly. Reading another user's capabilities is gated: it requires the caller to hold ``ALTER_PRIVILEGES`` or be an admin, otherwise an error JSON is returned. This handler is read-only and mutates no state. Registered via the single-tool module contract (``TOOL_NAME`` / ``run``) and dispatched by ``tool_loader.load_tools``; the same decode is also surfaced to the ``!capsh`` chat command through ``message_processor.proxy_status_commands.capsh_text``. Args: target_user_id (str): User ID to inspect; empty means the calling user (``ctx.user_id``). guild_id (str): Guild scope to evaluate; empty falls back to ``ctx.guild_id``. channel_id (str): Channel scope to evaluate; empty falls back to ``ctx.channel_id``. Channel masks are only fetched when a guild is known. ctx (ToolContext | None): The tool context supplying ``redis``, ``config``, ``user_id``, and the guild/channel context. Returns: str: A pretty-printed JSON dump with per-scope masks, the effective mask, per-bit details, scope diffs, resolution-mode legend, and the escalation ceiling; or a JSON ``{"error": ...}`` string when there is no context, Redis is unavailable, no target user can be determined, or the caller lacks ``ALTER_PRIVILEGES`` to read another user. """ if ctx is None: return json.dumps({"error": "No tool context available."}) redis = ctx.redis if redis is None: return json.dumps({"error": "Redis unavailable."}) config = ctx.config caller_id = ctx.user_id or "" from tools.alter_privileges import ( PRIVILEGES, DANGEROUS_BITS, _bit_mode, _is_admin, _mask_to_names, _get_scoped_mask, get_user_privileges, resolve_privilege_bit, ) target = (target_user_id or "").strip() or caller_id if not target: return json.dumps({"error": "No user ID available."}) # Permission check: reading another user requires ALTER_PRIVILEGES if target != caller_id: caller_mask = await get_user_privileges(redis, caller_id, config) if not (caller_mask & (1 << PRIVILEGES["ALTER_PRIVILEGES"])) and not _is_admin( caller_id, config ): return json.dumps( { "error": "Reading another user's capabilities requires ALTER_PRIVILEGES.", } ) # Fetch all three scope masks global_mask = await get_user_privileges(redis, target, config) is_admin = _is_admin(target, config) # Use explicit params, fall back to caller context eval_guild = (guild_id or "").strip() or (ctx.guild_id or "") eval_channel = (channel_id or "").strip() or (ctx.channel_id or "") guild_mask: int | None = None channel_mask: int | None = None if eval_guild: guild_mask = await _get_scoped_mask(redis, "guild", eval_guild, None, target) if eval_guild and eval_channel: channel_mask = await _get_scoped_mask( redis, "channel", eval_guild, eval_channel, target ) # Compute effective mask effective = 0 for bit in range(64): if resolve_privilege_bit(bit, global_mask, guild_mask, channel_mask): effective |= 1 << bit # Build per-bit detail for set bits (union of all scopes) all_set_bits = set() for bit in range(64): if any( ( global_mask & (1 << bit), guild_mask is not None and guild_mask & (1 << bit), channel_mask is not None and channel_mask & (1 << bit), effective & (1 << bit), ) ): all_set_bits.add(bit) _reverse = {v: k for k, v in PRIVILEGES.items()} bit_details: list[dict[str, Any]] = [] for bit in sorted(all_set_bits): name = _reverse.get(bit, f"bit_{bit}") mode = _bit_mode(bit) mode_label = _MODE_LABELS.get(mode, "NORMAL") detail: dict[str, Any] = { "bit": bit, "name": name, "mode": mode_label, "global": bool(global_mask & (1 << bit)), } if guild_mask is not None: detail["guild"] = bool(guild_mask & (1 << bit)) if channel_mask is not None: detail["channel"] = bool(channel_mask & (1 << bit)) detail["effective"] = bool(effective & (1 << bit)) # Show override status global_val = bool(global_mask & (1 << bit)) eff_val = detail["effective"] if eff_val != global_val: if eff_val: detail["override"] = "GRANTED by scope" else: detail["override"] = "REVOKED by scope" bit_details.append(detail) # Diff: what changed from global → effective granted_by_scope = ( _mask_to_names(effective & ~global_mask) if effective & ~global_mask else [] ) revoked_by_scope = ( _mask_to_names(global_mask & ~effective) if global_mask & ~effective else [] ) result: dict[str, Any] = { "user_id": target, "is_admin": is_admin, "context": { "guild_id": eval_guild or "(none)", "channel_id": eval_channel or "(none)", }, "masks": { "global": {"hex": hex(global_mask), "active": _mask_to_names(global_mask)}, }, "effective": { "hex": hex(effective), "active": _mask_to_names(effective), }, "bit_details": bit_details, "dangerous_bits": [_reverse.get(b, f"bit_{b}") for b in sorted(DANGEROUS_BITS)], "resolution_modes": { "NORMAL": "channel > guild > global (most specific wins)", "INVERTED": "global > guild > channel (most general wins)", "DANGEROUS": "global only, scoped masks ignored", }, } if guild_mask is not None: result["masks"]["guild"] = { "hex": hex(guild_mask), "active": _mask_to_names(guild_mask), } if channel_mask is not None: result["masks"]["channel"] = { "hex": hex(channel_mask), "active": _mask_to_names(channel_mask), } if granted_by_scope: result["scope_diff"] = result.get("scope_diff", {}) result["scope_diff"]["granted_by_scope"] = granted_by_scope if revoked_by_scope: result["scope_diff"] = result.get("scope_diff", {}) result["scope_diff"]["revoked_by_scope"] = revoked_by_scope # ------------------------------------------------------------------ # Escalation ceiling: if this user went fully adversarial, # what is the maximum they could self-grant? # ------------------------------------------------------------------ from tools.alter_privileges import ALL_BITS # Non-dangerous mask: all 64 bits minus DANGEROUS ones non_dangerous_mask = ALL_BITS for db in DANGEROUS_BITS: non_dangerous_mask &= ~(1 << db) has_alter = bool(effective & (1 << PRIVILEGES["ALTER_PRIVILEGES"])) or is_admin has_guild_admin = bool(effective & (1 << PRIVILEGES["GUILD_ADMIN"])) has_channel_admin = bool(effective & (1 << PRIVILEGES["CHANNEL_ADMIN"])) ceiling = effective # Start with what they already have if has_alter or is_admin: # ALTER_PRIVILEGES or admin: can grant ALL bits globally ceiling = ALL_BITS escalation_path = "ALTER_PRIVILEGES → full global escalation (all 64 bits)" elif has_guild_admin: # Can self-grant any non-DANGEROUS bit at guild scope ceiling = effective | non_dangerous_mask escalation_path = "GUILD_ADMIN → can grant any non-DANGEROUS bit at guild scope" elif has_channel_admin: # Can self-grant any non-DANGEROUS bit at channel scope ceiling = effective | non_dangerous_mask escalation_path = ( "CHANNEL_ADMIN → can grant any non-DANGEROUS bit at channel scope" ) else: escalation_path = "No self-escalation path (no admin bits)" # What bits could they gain that they don't currently have? gainable = ceiling & ~effective result["escalation_ceiling"] = { "max_reachable_hex": hex(ceiling), "max_reachable_active": _mask_to_names(ceiling), "path": escalation_path, "gainable": _mask_to_names(gainable) if gainable else [], "is_capped": ceiling == effective, } return json.dumps(result, indent=2)