"""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)