Skip to content

Sender Allowlist (Layer 0)

Every WebSocket message is gated on the trusted-sender allowlist before any other layer applies. This page covers the three sources that build the allowlist, the mandatory-strict-mode startup behavior, and the authorize-mention OOB flow that lets operators admit a non-trusted user into one specific chat without restarting.

See Permission Matrix for how Layer 0 relates to the other layers.

How the allowlist is built

When ringclaw start boots, the WebSocket monitor and message handler both switch into strict sender mode: only the user IDs on the trusted allowlist may drive the AI agent. The allowlist is the union of three sources:

  1. The Private App owner's user ID (auto-injected when a Private App is configured).
  2. All entries in ringcentral.source_user_ids (resolved to numeric user IDs on startup).
  3. All entries in ringcentral.chat_user_allow[<chatID>] for the destination chat (resolved on startup the same way as source_user_ids). This is a per-chat layered exception, not a global widening — chat_user_allow only admits the listed users in the listed chats.

If all three sources are empty, the bot logs a startup error and drops every incoming message until the operator adds at least one trusted sender. This prevents the "any user in an allowed chat can run my AI agent" foot-gun.

yaml
ringcentral:
  source_user_ids:
    - "+15551234567"       # phone number, resolved at boot
    - alice@example.com    # email address, resolved via Private App directory
    - "987654321"          # bare numeric extensionId / user ID

TIP

Email and phone-number entries require a Private App with the ReadAccounts permission so they can be resolved to numeric IDs. Without the Private App, list the numeric extensionIds directly.

chat_user_allow — per-chat exception layer

ringcentral.chat_user_allow is a per-chat allowlist layered on top of source_user_ids:

jsonc
{
  "ringcentral": {
    "chat_user_allow": {
      "chat-engineering-7": ["alice@example.com", "3061708020"],
      "chat-design-9":      ["bob@example.com"]
    }
  }
}

Identifiers may be numeric extension IDs, emails, or E.164 phone numbers (resolved to numeric IDs at startup via the Private App directory). Operators may pre-seed by hand, or let the authorize-mention OOB flow populate the map on operator approval.

Key invariant: chat_user_allow only widens the sender allowlist for the listed chat. It does not unlock privileged Layer 1 commands (/cwd, /cron, /new, /reload, /full-access, summarize NL triggers) — those still require Private-App-owner identity. See Command Authorization.

Authorize-mention OOB flow

SECURITY ADVISORY (v0.4.2 → v0.4.3)

Before v0.4.3, any user that became "trusted" — whether through source_user_ids, chat_user_allow, or the v0.4.1 OOB-approval flow — drove the same agent backend the bot operator uses, and could request filesystem (List, Read, Write), terminal (Bash), and external HTTP tool calls through the agent tool-call channel.

v0.4.3 (this release) — fail-closed two-tier non-owner isolation for fs/* + terminal/*:

  • "Owner" is now strictly the source_user_ids set (plus the resolved Private App owner). chat_user_allow users and any v0.4.0 OOB-approved users are non-owners and run with the reduced ceiling described below.
  • Layer A (protocol): ringclaw issues session/set_mode <restricted> immediately after creating an ACP session for a non-owner. The modeID is picked from a per-agent map: droid → spec, claude → plan, gemini → plan, qwen → plan, cursor-agent → plan. Unknown agents fall back to a heuristic that scans availableModes for plan / spec / read / safe.
  • Layer B (client gate, fail-closed): ringclaw rejects every fs/read_text_file / fs/write_text_file / terminal/create / terminal/output / terminal/wait_for_exit / terminal/kill / terminal/release JSON-RPC request issued from a non-owner session, regardless of the agent's mode behavior. session/request_permission is also denied (the agent gets either the offered kind=deny option or a cancelled outcome).
  • Fail-closed if no restricted mode exists. When the agent does not advertise any read-only mode and the operator did not configure an override, the non-owner message is not forwarded to the agent. The user receives a refusal text; audit log emits restricted_mode_unsupported_no_mode.
  • chat_user_allow is preserved across restarts (v0.4.4+). The v0.4.2 startup force-clear is removed now that v0.4.3 enforces the non-owner ceiling on these sessions. Listed users are admitted at Layer 0, then run under the same fail-closed ceiling described above.

Layer-A known limitations (best-effort only): upstream agents do not always enforce the read-only mode by themselves (qwen-code#1806 returns success without enforcement; gemini-cli#22191 has known plan-mode bugs over ACP). Layer B is the actual security boundary for fs/* + terminal/*. WebFetch / WebSearch / built-in HTTP tools / MCP custom tools are dispatched directly by the agent process and ringclaw does not see those JSON-RPC requests, so Layer B cannot apply — those remain best-effort under Layer A only. v0.5.0 (planned) will close this gap with OS-level sandboxing.

Operator-facing override: the per-agent default modeID can be overridden via agents.<name>.restricted_mode_id in config.json. The override must match a mode the agent advertises in its availableModes list, otherwise the built-in selection still wins.

A separate OOB surface, layered on the same challenge / terminal-approval infrastructure as /full-access and the non-owner cross-chat ACTION challenge, lets operators authorize per-chat non-trusted senders without restarting or hand-editing config.json. The feature is controlled by ringcentral.allow_group_mention_authorize:

  • Unset (default since v0.4.2): feature off. Non-trusted group @bot mentions are silently dropped — same as the v0.4.0 baseline.
  • true: feature on. Non-trusted group @bot mentions surface as a /approval prompt in the owner DM. Requires a Private App + resolved owner DM at runtime; otherwise the feature is disabled at startup with an ERROR log. ringclaw also emits a startup WARN reminding operators that approved users currently get full agent capability.
  • false: feature off, explicitly. Same behavior as unset.

The trigger is narrow: a user who is not on the global source_user_ids allowlist and not in the destination chat's chat_user_allow entry sends a message with @bot (a true mention) in an allowed group chat. Plain text from the same user, or @bot in a non-allowed chat, still drops as before.

Key invariants

  • Original message dropped. The first @bot that triggered the challenge is not replayed on approval. The user must @bot again to actually drive the AI. This avoids accidental side-effects from prompts that were authored before the operator approved them.
  • Per-chat scope only. The grant is recorded under chat_user_allow[<chatID>], never in the global source_user_ids. Approving a user in chat A does not authorize them in chat B.
  • Privileged Layer 1 commands NOT unlocked. chat_user_allow only widens Layer 0. Non-owner privileged Layer 1 commands still require Private-App-owner identity. See Command Authorization.

Layer 2 (agent tool calls) — non-owner ceiling (v0.4.3+)

Listed users (chat_user_allow / OOB-approved) can drive the AI agent like any trusted sender, but v0.4.3 enforces a fail-closed ceiling on their session:

  • fs/read_text_file, fs/write_text_file, terminal/create / output / wait_for_exit / kill / release, and session/request_permission are denied at the ringclaw client. The agent is told the call failed with code=-32001 ("denied for non-owner senders").
  • The session is also asked to switch to a read-only mode (spec for droid, plan for the others) as a defense-in-depth layer. When the agent does not support a suitable mode, the message is refused outright rather than forwarded.
  • Plain text replies and RingCentral ACTION:MESSAGE / TASK / NOTE / EVENT blocks remain available (the latter go through the RingCentral REST API, not ACP).

What is not covered by Layer B: WebFetch, WebSearch, and MCP custom tools. The agent dispatches those internally; ringclaw cannot see them. v0.5.0 will close that gap with OS-level sandboxing. Until then, treat a chat_user_allow user as "can read the public web through the agent and make the agent talk", but no longer "can shell into your host".

  • Pending dedupe. A pending challenge for a (chatID, userID) pair blocks new challenges from the same pair for the challenge TTL. The pending lock is released on approve / deny / expire / prompt-post failure.
  • 24h cooldown after deny / expire (v0.4.1+). When a challenge resolves to deny or expire, the same (chatID, userID) pair is silenced for 24 hours: subsequent @bot mentions are dropped without re-prompting the owner. This keeps a noisy or hostile non-trusted user from spamming the owner DM by repeatedly re-mentioning the bot. The window does not apply on approve (the user becomes trusted via chat_user_allow instead) or on transient errors (e.g. owner DM post failed) so the operator always gets another chance once the underlying issue clears. Cooldown state is in-memory only — restarting the bot resets it, which is acceptable because restarts are rare and operator-driven.
  • Persistence is best-effort. The in-memory monitor + handler allowlists are updated synchronously on approval; the config.json Save is fired afterwards. A persist failure is logged as ERROR authorize-mention: persist failed but the user remains authorized for the current process lifetime — operators relying on durable persistence should monitor that log line.
  • Email preferred for persistence. The persisted identifier is the resolved email when the directory lookup succeeds; the numeric extension ID otherwise (with a WARN authorize-mention: no email available line). Hand-edited entries may use any of the three forms source_user_ids accepts.
  • No new approval verb. The same ringclaw approval <id> / ringclaw approval deny <id> CLI handles authorize-mention, /full-access, and cross-chat OOB challenges uniformly. The challenge intent field disambiguates them in audit logs. See Approval CLI.
  • Owner self-challenge guarded. The handler refuses to issue an authorize-mention challenge when post.CreatorID equals the Private App owner's ID. Monitor's Layer 0 already admits the owner, so reaching this path with that condition would only happen on a bug or hostile direct caller — failing closed prevents the owner from being routed a "user X requesting authorization" prompt for themselves.

Failure modes

ConditionBehavior
allow_group_mention_authorize unset (default since v0.4.2)Feature off. Non-trusted @bot is silently dropped (v0.4.0-style baseline).
allow_group_mention_authorize: falseFeature off, explicitly. Same as unset.
allow_group_mention_authorize: trueFeature on. ringclaw emits a startup INFO reminding the operator that approved users run under the v0.4.3+ non-owner ceiling.
Private App not configured (no owner DM resolvable)Feature disabled at startup with ERROR log; falls back to silent drop.
chat_user_allow entry resolves to zero numeric IDsv0.4.4: WARN chat_user_allow entry resolved to zero numeric IDs with the offending raw identifiers. Most common cause: the chat ID field is a team display name instead of the numeric chat ID. Other causes: identifier not in the directory; Private App lacks ReadAccounts.
Non-owner sender hits an agent with no read-only modev0.4.3 fail-closed: the message is not forwarded; the user receives a refusal reply; audit log emits restricted_mode_unsupported_no_mode.
Non-owner sender + agent rejects session/set_modev0.4.3 fail-closed: same as above; the (agentCmd, modeID) pair is cached so subsequent attempts skip the RPC.
Owner DM not yet resolved at runtimeThe single message that hits this race is dropped with WARN authorize-mention: OOB or owner DM unconfigured; dropping. Subsequent messages succeed once the DM resolves.
Owner denies the challengePending lock released; owner DM notified. The (chat, user) pair enters a 24h cooldown — re-mentions drop silently until the window elapses.
Challenge expires (5 min TTL)Pending lock released; owner DM notified. Same 24h cooldown as deny.
Owner DM post fails (transient RC error)Challenge auto-denied; pending lock released. No cooldown is recorded so the user's next @bot retries the post.
Persist callback fails (e.g. config write error)In-memory grant survives the current process; ERROR authorize-mention: persist failed is logged. Restart re-locks the user.

Pre-seeding without OOB

Operators who want to authorize a known user without enabling the OOB flow at all can hand-edit chat_user_allow directly:

jsonc
{
  "ringcentral": {
    "allow_group_mention_authorize": false,  // OOB flow off
    "chat_user_allow": {
      "800123456": ["alice@example.com"]
    }
  }
}

This admits Alice in chat 800123456 and only there; no challenge is ever issued. The two fields are independent. The map key must be the numeric RC chat / group ID (see How to find a chat ID) — a team display name will silently match nothing.

Enabling OOB approval for group members

Use this checklist when a non-owner group member needs to drive the agent in a specific group chat. Approved users run under the v0.4.3+ non-owner ceiling: text replies + RC ACTION:MESSAGE / TASK / NOTE / EVENT blocks remain available; fs/*, terminal/* and session/request_permission are denied at the JSON-RPC layer.

Prerequisites (all four required)

  1. Private App configured. OOB needs to read the owner DM. The bot client alone cannot see DMs to itself, so the Private App uses the operator's identity. Without ringcentral.private_app the OOB flow is disabled at startup with an ERROR log.
  2. Owner has DM-ed the bot at least once. ringclaw resolves ownerDMChatID from that conversation. If the resolution fails you'll see ERROR allow_group_mention_authorize requires Private App + resolved owner DM; feature disabled.
  3. chat_ids includes the target group's numeric chat ID. Messages from chats not on this list are dropped before the sender allowlist is even consulted.
  4. source_user_ids is non-empty (the Private App owner is auto-injected when the Private App resolves) so strict-mode does not drop everything.

Minimal config

jsonc
{
  "ringcentral": {
    "bot": {
      "server_url": "https://platform.ringcentral.com",
      "token":      "<bot oauth token>"
    },
    "private_app": {
      "server_url":    "https://platform.ringcentral.com",
      "client_id":     "...",
      "client_secret": "...",
      "jwt":           "..."
    },
    "chat_ids":                       ["800123456"],
    "source_user_ids":                ["owner@yourcompany.com"],
    "group_mention_only":             true,
    "allow_group_mention_authorize":  true,
    "chat_user_allow":                {}
  },
  "agents": {
    "droid": {
      "type": "acp",
      "command": "droid",
      "restricted_mode_id": "spec"
    }
  }
}

Leave chat_user_allow empty — OOB approval will fill it in. You can pre-seed it (see Pre-seeding without OOB) but the key must be a numeric chat ID, not a team display name.

Expected startup log lines

When everything is wired up, you should see, in order:

  1. INFO source_user_ids resolved with your owner ID.
  2. INFO authorize-mention OOB enabled — approved users run under v0.4.3+ non-owner ceiling …
  3. INFO authorize-mention OOB flow active ownerDMChatID=…

If you see ERROR allow_group_mention_authorize requires Private App + resolved owner DM: prerequisites #1 or #2 are missing.

If you see WARN chat_user_allow entry resolved to zero numeric IDs: a chat key is wrong (most often a team name instead of the numeric chat ID), or an identifier is not in the directory.

Approval flow

  1. Group member @bot how do I … in the configured group.
  2. ringclaw drops the original post (it is not replayed on approval) and posts an /approval <id> prompt to the owner DM.
  3. Operator runs ringclaw approval <id> on the host where the bot is running.
  4. ringclaw writes the approved user into chat_user_allow[<chatID>] on disk (v0.4.4+: this entry survives restarts) and into the live Monitor + Handler allowlists.
  5. Group member @bot again — this time the message is dispatched to the agent under the non-owner ceiling.

The same (chat, user) pair is silenced for 24h after a deny or expire to prevent repeat owner-DM spam (see Key invariants).

How to find a chat ID

chat_ids and chat_user_allow keys both expect a numeric RC chat / group ID (typically a 9–10 digit string like "800123456"). A team / group display name will silently match nothing. Three ways to obtain it:

  1. RingCentral client URL. Open the chat in the RingCentral web or desktop client. The URL ends with /r/<chatID> — copy that number.
  2. ringclaw debug log. With RINGCLAW_LOG_LEVEL=debug, post any message in the chat and watch for monitor lines such as ignoring message from non-allowed chat chatID=… — that field is the chat ID.
  3. REST glip/groups. Call GET /restapi/v1.0/glip/groups?recordCount=250 with the Private App credentials and find the entry where name matches your team. The id field is the chat ID.

Audit-log additions

EventLog linePurpose
Sender allowlist empty at startupsender allowlist is empty: ...Canonical signal that strict mode dropped to deny-all because no source_user_ids and no Private App owner are configured.
Authorize-mention routing (monitor)INFO authorize-mention: routing non-trusted group mention (chatID, userID)Confirms the WebSocket monitor handed a non-trusted group @bot to the OOB flow instead of dropping it.
Authorize-mention challenge issuedINFO oob: challenge issued (intent starts with authorize user … in chat …)Same INFO oob: challenge issued line as /full-access and cross-chat OOB; the intent field disambiguates.
Authorize-mention grantedINFO authorize-mention: granted (challengeID, chatID, userID, identifier)Fired after applyAuthorize updates the in-memory monitor + handler allowlists and (best-effort) persists the identifier to chat_user_allow.
Authorize-mention denied / expiredINFO authorize-mention: denied or INFO authorize-mention: challenge expired (with cooldown=24h)Counterparts to the granted line. The pending (chat, user) dedupe is released and a 24h silence window is recorded.
Authorize-mention dropped by cooldownDEBUG authorize-mention: in cooldown after recent deny/expire, droppingA re-mention from a (chat, user) pair within their 24h silence window is dropped without re-prompting the owner.
Authorize-mention persist failureERROR authorize-mention: persist failed (with error)The in-memory grant succeeded but writing config.json did not — the grant survives the current process but is lost on restart.
Authorize-mention prompt failureERROR authorize-mention: post prompt failed (with error)The owner DM post failed; the challenge is auto-denied and the (chat, user) pending lock released so the user can retry.
Authorize-mention email unavailableWARN authorize-mention: no email available, persisting numeric IDDirectory lookup did not yield an email; the numeric extension ID is persisted instead (still chat-scoped).
v0.4.3: restricted mode applied to non-ownerWARN acp restricted-mode event (event=restricted_mode_applied, mode_id, mode_source, conversation, sender_id)Layer-A success: agent accepted the read-only mode for a non-owner conversation.
v0.4.3: restricted mode unsupported (no candidate)WARN acp restricted-mode event (event=restricted_mode_unsupported_no_mode, available_modes)Layer-A fail-closed: no built-in / heuristic match. Non-owner message refused.
v0.4.3: restricted mode unsupported (set_mode rejected)WARN acp restricted-mode event (event=restricted_mode_unsupported, error="Method not found")Layer-A fail-closed: agent rejected the call. Cached so the next attempt skips.
v0.4.3: Layer-B tool call deniedWARN acp non-owner tool call denied (event=tool_call_denied, method, session, reason)Client-side fail-closed deny of a fs/* / terminal/* / session/request_permission request. Deduped per (session, method).
v0.4.4: chat_user_allow loadedINFO chat_user_allow resolved (v0.4.3+ non-owner ceiling enforced for these users) (chats, users)Per-chat exceptions read from config.json and pushed into Monitor + Handler. Replaces the v0.4.2 force-clear.
v0.4.4: chat_user_allow entry resolved to nothingWARN chat_user_allow entry resolved to zero numeric IDs (chatID, rawIdentifiers)A chat key (or one of its identifiers) failed to resolve. Most common cause: the key is a team display name rather than the numeric chat ID.