Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1445-run-bot-team-tools.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Workflow run Bot is no longer a team member: `tools` on its node only decides connectors and the shell, so the host now refuses every team-reaching tool (`message_bot`, `stop_bot`, bot/group/capability writes, `request_secret`, schedule writes, the social and workflow tools, `recall`, …) with one sentence, hides the same names from the run Bot's own tool list, and answers `rejected` for them in the approval seam (#1445).
23 changes: 18 additions & 5 deletions core/src/host/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,28 @@
// the group chat. A Bot's own chat keeps DSH's card; its requests are only noted, so
// the team view can say who is waiting.
import { randomUUID } from 'node:crypto'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'
import { OWNER, settled } from './turn-origin.js'

const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'allowed-once', 'always-room': 'allowed-once' }
const RUN_DENIED = new Set(EPHEMERAL_DENIED_TOOLS)

// DSH has no standing grant (its outcomes are one-shot, #540), so the owner's "Always
// allow" lives here: on the Bot or on the group, keyed by the kind of permission asked.
// A sandbox escalation names its mode in DSH's reason; any other ask is keyed by tool.
// A run Bot has no chat the owner can see, so an approval card would stall the
// step. Connector tools are allowed only when the node's stepTools name them
// (`github` covers `mcp__github__*`). A shell escalation to full access needs
// `bash`, `shell` or `danger-full-access`. Other calls follow the Bot's preset
// and are answered here, because nobody can tap the card.
// `bash`, `shell` or `danger-full-access`. The team-reaching tools are `rejected`
// whatever the step names, the same names connector-access.js refuses in its guard.
// Other calls follow the Bot's preset and are answered here, because nobody can
// tap the card.
export function ephemeralToolVerdict(bot, toolName, reason) {
if (!bot?.ephemeral) return undefined
const tool = String(toolName ?? '')
if (RUN_DENIED.has(tool)) return 'rejected'
const tools = new Set((bot.stepTools ?? []).map(name => String(name).toLowerCase()))
const grant = (name) => tools.has('*') || tools.has(String(name).toLowerCase())
const tool = String(toolName ?? '')
if (tool.startsWith('mcp__')) {
const id = tool.split('__')[1] ?? ''
return grant(tool) || (id !== '' && grant(id)) ? 'allowed-once' : 'rejected'
Expand Down Expand Up @@ -48,8 +53,16 @@ export function install(rt) {
const sessionId = request.agent?.session?.id ?? request.agent?.id
const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId)
const botId = owner?.botId ?? (sessionId === undefined ? undefined : rt.chatOwners.get(sessionId))
if (botId === undefined) return next()
const step = ephemeralToolVerdict(rt.botOf(botId), String(request.toolName ?? ''), request.reason)
// A subagent child Session started by a run Bot is in neither index. Follow the parent chain
// (the same `rt.whoseAgent` secrets.js and connector-access.js use) and adopt the run Bot's
// answer there; an ordinary Bot's child keeps falling through to next() exactly as before.
let bot = botId === undefined ? undefined : rt.botOf(botId)
if (botId === undefined) {
const parent = rt.whoseAgent?.(request.agent)
if (parent?.bot?.ephemeral === undefined) return next()
bot = parent.bot
}
const step = ephemeralToolVerdict(bot, String(request.toolName ?? ''), request.reason)
if (step !== undefined) return step
if (standingCovers(sessionId, botId, owner?.roomId, approvalKind(String(request.toolName ?? ''), request.reason))) return 'allowed-once'
const id = randomUUID()
Expand Down
10 changes: 9 additions & 1 deletion core/src/host/connector-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
// The switches change only through `bot-connectors` on the channel, which Bots do not call.

import { GITHUB, COMPOSIO } from './connectors.js'
import { EPHEMERAL_DENIED_TOOLS, EPHEMERAL_REFUSAL } from './tools.js'
import { OWNER, OTHER, settled } from './turn-origin.js'

export const CONNECTOR_IDS = [GITHUB, COMPOSIO]
Expand All @@ -36,6 +37,7 @@ export const connectorOfTool = name => (typeof name === 'string' ? CONNECTOR_IDS
// refuses a name this profile does not serve, so a name missing here only means the tool stays.
export const SCHEDULE_TOOLS = ['schedule_create', 'schedule_list', 'schedule_update', 'schedule_delete']
const SCHEDULES = new Set(SCHEDULE_TOOLS)
const RUN_DENIED = new Set(EPHEMERAL_DENIED_TOOLS)
const MAX_DEPTH = 8

export function install(rt) {
Expand Down Expand Up @@ -102,8 +104,14 @@ export function install(rt) {
const name = exec?.name
const id = connectorOfTool(name)
const schedule = SCHEDULES.has(name)
if (id === undefined && !schedule) return undefined
const team = RUN_DENIED.has(name)
if (id === undefined && !schedule && !team) return undefined
const who = whoseAgent(exec.agent)
// A run Bot is not a team member: a tool whose effect reaches past its own Session is refused
// whatever the node's `tools` names, before the connector or the room answer. Everything that
// stays (list_bots, the memory reads and writes of its own scope, …) is in
// EPHEMERAL_ALLOWED_TOOLS next to it, and approvals.js answers `rejected` for the same names.
if (team && who.bot?.ephemeral !== undefined) return EPHEMERAL_REFUSAL
if (who.plain === true) return undefined
// #1237: a room Session is one of the Bot's own Sessions (store.js indexes it as groupOwners), so
// the room rule reads the same session id whoseAgent already resolved. The tip is in the reason:
Expand Down
25 changes: 23 additions & 2 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { faceMentions, mentionedByHandle } from './face-mentions.js'
import { FEED_EVENTS, installFeed } from './flybot-feed.js'
import { createPluginModel } from './plugin-model.js'
import { defineTool } from './tool-schema.js'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'

export const FLYBOT_API_VERSION = 1
export const SHARED = 'shared'
Expand Down Expand Up @@ -823,9 +824,29 @@ export function install(rt) {
// it as known, or every other resolve lifts the mask and the next sets it again, and the
// chat shows "Tool added" / "Tool removed" on each turn (#276).
for (const name of current?.deny ?? []) known.add(name)
const deny = [...gatedTools.values()].filter(def => known.has(def.name) && !visible(def, bot.id)).map(def => def.name).sort()
const key = deny.join(',')
const pluginDeny = [...gatedTools.values()].filter(def => known.has(def.name) && !visible(def, bot.id)).map(def => def.name)
let deny = pluginDeny
let stays = []
if (bot.ephemeral !== undefined) {
// A run Bot is not a team member: the team-reaching names are hidden through the same mask
// the guard refuses with, so a node's own list agrees with the refusal. restrict() can name
// only what the global view serves; a name the Agent itself serves stays listed — the guard,
// not the list, is the refusal — and the warn line says which one it was.
const runNames = EPHEMERAL_DENIED_TOOLS.filter(name => known.has(name))
let global
try { global = ctx.tools.schemas?.() } catch { global = undefined }
if (global === undefined) {
stays = runNames
} else {
const maskable = new Set(global.map(schema => schema.name))
deny = [...pluginDeny, ...runNames.filter(name => maskable.has(name))]
stays = runNames.filter(name => !maskable.has(name))
}
}
deny = [...new Set(deny)].sort()
const key = `${deny.join(',')}|${stays.join(',')}`
if ((current?.key ?? '') === key) return
if (stays.length > 0) warn('flybot: %s stay listed for %s: the tool mask cannot name a tool the Agent serves itself', stays.join(', '), bot.name)
try {
current?.dispose?.()
masks.set(agent, { key, deny, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined })
Expand Down
31 changes: 31 additions & 0 deletions core/src/host/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@ import { questionReply, replyText } from './parts.js'
import { OTHER, settled } from './turn-origin.js'
import { cleanName } from './delivery.js'

// A run Bot (ephemeral) is not a team member: every tool below has an effect that reaches past
// the run Bot's own Session — it messages, stops or edits another Bot, edits a group, writes or
// attaches a capability, asks the owner for a secret, arms or changes a schedule, reads the team
// memory, sends a file, starts a workflow or reaches the browser. connector-access.js refuses
// each call in `ctx.tools.guard` and approvals.js answers `rejected` for the same names, so the
// two seams cannot disagree. The names span modules (this host, the harness, plugins/social,
// plugins/workflow, plugins/tree); a tool added to any of them lands in neither list here and
// fails `core/test/run-bot-tools.test.js` until someone classifies it.
export const EPHEMERAL_DENIED_TOOLS = Object.freeze([
'message_bot', 'stop_bot', 'create_bot', 'update_bot',
'create_group', 'update_group', 'delete_group', 'post_to_group',
'ask_user', 'save_capability', 'delete_capability', 'attach_capability', 'detach_capability',
'track_tasks', 'group_relay',
'request_secret', 'list_secrets',
'send_message', 'schedule_create', 'schedule_update', 'schedule_delete',
'message_friend_bot', 'get_friend_file', 'list_friend_bots',
'define_workflow', 'run_workflow',
'read_browser', 'recall', 'create_child_bot',
])
// The team-facing names a run Bot keeps: read-only, and each reads only what the run Bot itself
// may see. `remember`/`forget` are the only writes, and `writeDenied` keeps them inside the run
// Bot's own scope (never `team`, never another Bot's memory). The names a run Bot is served minus
// EPHEMERAL_DENIED_TOOLS equal this list; a new tool is classified here or in the denied constant.
export const EPHEMERAL_ALLOWED_TOOLS = Object.freeze([
'list_bots', 'list_models', 'list_capabilities', 'bot_schedules', 'schedule_list',
'read_own_chat', 'read_group_chat', 'remember', 'forget',
])
// One sentence that names the reason and no substitute route: the run Bot cannot become a team
// member by finding another tool that does the same thing.
export const EPHEMERAL_REFUSAL = 'A running Bot is not a team member.'

export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' }
const BLOCKING_QUESTION_TOOL = 'ask_user_question'
Expand Down
Loading
Loading