Component Development · Part 3 of 3 — Permissions

Defining Component Permissions

This part is mainly for components that bridge a third-party messaging channel. Once other people's messages arrive through an interface you wrote, you have to answer two questions: may the sender call this component's commands (TOOL_POLICY), and which tools may the task triggered by that push use (TRIGGER_TOOL_POLICY).

Prerequisite: the component can receive external messages and hand them to the Agent. Purely local utility components take no inbound messages and rarely need this part.

1. Command level: who may call which command

Declare TOOL_POLICY on the component class. Keys are bare command names (no component prefix); values decide who is allowed.

class NapcatComponent(BaseCell):
    # Keys are bare command names, no component prefix
    TOOL_POLICY = {
        "reset_config": "deny",                                      # nobody
        "add_user": "admin",                                         # admins only
        "shutdown": {"mode": "blacklist", "users": ["12345"]},       # deny listed users
        "send_private": {"mode": "whitelist", "users": ["10001"]},   # listed users only
    }
    TOOL_POLICY_DEFAULT = "allow"   # undeclared commands allowed; set to "deny" to block them all

Five values

ValueEffect
"allow"Allowed (default)
"deny"Nobody
"admin"Admins only
{"mode": "whitelist", "users": [...]}Listed users only
{"mode": "blacklist", "users": [...]}Everyone except listed users

TOOL_POLICY_DEFAULT governs undeclared commands: allow by default; switch to deny so only explicitly allowed commands work, which suits sensitive components.

2. How admin is decided

BaseCell already implements it: it reads tool_allowed from platform_context, falling back to is_admin. The QQ channel passes tool_allowed automatically, so most components need no code. Override is_policy_admin to plug in your own admin list.

# Default implementation (built into BaseCell)
# tool_allowed wins, then is_admin, otherwise not an admin

# Override to use your own admin list
def is_policy_admin(self, platform_context=None) -> bool:
    user_id = str((platform_context or {}).get("user_id") or "")
    return user_id in {"10001", "10002"}   # your admin QQ numbers

Any exception inside this check is treated as not-an-admin, so never raise here — return False yourself.

3. Full example: admins manage cities, everyone queries weather

add_city / remove_city require an admin; query is undeclared and therefore allowed.

class WeatherBot(BaseCell):
    TOOL_POLICY = {
        "add_city": "admin",       # admin only
        "remove_city": "admin",    # admin only
        # query is undeclared -> allowed for everyone
    }

    def is_policy_admin(self, platform_context=None) -> bool:
        user_id = str((platform_context or {}).get("user_id") or "")
        return user_id in {"10001", "10002"}

    def _cmd_query(self, city: str = "", **kwargs) -> Dict[str, Any]:
        return {"success": True, "weather": f"{city} sunny 25C"}

    def _cmd_add_city(self, city: str = "", **kwargs) -> Dict[str, Any]:
        ...

A user asking for Beijing weather runs normally; asking to add Shanghai makes the agent call add_city, which is rejected, and the agent tells the user they lack permission.

# The call to add_city is blocked; the tool returns:
{
  "success": False,
  "error": "命令 add_city 仅管理员可用",
  "_source": "component:weather_bot"
}
# The agent reports the lack of permission instead of retrying

4. Task level: which tools a triggered task may use

TOOL_POLICY only governs this component's own commands. To restrict what tasks triggered through this component may use (shell, sub_agent, ...), declare TRIGGER_TOOL_POLICY. It accepts tool names and tool.command keys for command-level precision.

class NapcatComponent(BaseCell):
    # Messages relayed through this component may not use these tools
    TRIGGER_TOOL_POLICY = {
        "mode": "blacklist",
        "tools": ["shell", "sub_agent", "qq_files.download"],
    }

Effect: a QQ user message triggers the agent through this component; shell is blocked, qq_files.download is blocked, other qq_files commands are unaffected. The trigger request's source must equal the component's cell_name for the declaration to apply.

class MonitorComponent(BaseCell):
    # Alert-triggered tasks may use only these two tools
    TRIGGER_TOOL_POLICY = {
        "mode": "whitelist",
        "tools": ["monitor", "napcat.send_msg"],
    }

    def _on_alert(self, alert):
        """Alert callback: triggers the agent with the restriction applied"""
        httpx.post(f"{self._base_url}/api/component/event", json={
            "session_id": "monitor:alerts",
            "message": f"CPU over threshold: {alert}",
            "source": self.cell_name,     # matched against the declaration above
            "platform_context": {
                "platform": "monitor",
                "user_id": alert.user_id,
            },
        })

5. Passing permissions per trigger request

No code declaration needed: put the policy in platform_context of /api/component/event.

FormEffect
"tool_allowed": falseEnable the system default blacklist (about 20 entries: shell, file, component, sub_agent, ...) — right for relaying ordinary user messages
tool_policy blacklistCustom blacklist, merged with the default one (add-only)
tool_policy whitelistOnly the listed tools are allowed, everything else is denied (default blacklist not involved)
POST /api/component/event
{
  "session_id": "qq:12345:group",
  "message": "handle this for me",
  "source": "napcat",
  "platform_context": {
    "platform": "qq",
    "user_id": "12345",
    "tool_allowed": false
  }
}

Relay example: ordinary users get qq_files only; admins carry no policy field and are unrestricted.

def _trigger_agent(self, message: str, user_id: str):
    is_admin = user_id in self._admins
    ctx = {"platform": "qq", "user_id": user_id, "message_type": "group"}
    if not is_admin:
        ctx["tool_policy"] = {"mode": "whitelist", "tools": ["qq_files", "napcat.send_msg"]}
    # admins carry no policy field -> unrestricted

    httpx.post(f"{self._base_url}/api/component/event", json={
        "session_id": f"qq:{user_id}",
        "message": message,
        "source": self.cell_name,
        "platform_context": ctx,
    })

Priority: an explicit tool_policy in the request wins, overriding the component's declaration. Components without TRIGGER_TOOL_POLICY behave exactly as before.

Decision table

GoalWhere to put it
Block a command for everyoneTOOL_POLICY = {"xxx": "deny"}
Make a command admin-only"xxx": "admin"
Restrict a command to listed users"xxx": {"mode": "whitelist", "users": [...]}
Block other tools for tasks triggered hereTRIGGER_TOOL_POLICY blacklist
Allow only a few tools for tasks triggered hereTRIGGER_TOOL_POLICY whitelist
Set permissions per trigger requestplatform_context + tool_allowed / tool_policy

Pitfalls

  1. Key formats differ: TOOL_POLICY keys are bare command names (add_user); TRIGGER_TOOL_POLICY keys are tool names or tool.command (qq_files.download). A wrong key does not error — it silently does nothing.
  2. Whitelist bypasses the default blacklist: listing shell allows shell. Use whitelists only for trusted callers.
  3. Blacklist is add-only: blacklist mode merges with the system default, so to allow a default-blocked tool you must switch to whitelist.
  4. Command-level matching needs the command argument: a key like qq_files.download only matches when command is passed explicitly; otherwise matching falls back to the bare tool name.
  5. WebUI calls carry no user_id: whitelist commands are therefore blocked in the WebUI too, while blacklist mode allows them.
Security

Blocking is a runtime rejection, not a removal from the tool list: forbidden tools remain visible to the LLM and fail on call. To stop the model from trying at all, constrain it via the prompt, not the policy table.