Skip to content

Commit ecd044e

Browse files
committed
fix(host): refuse the team tools to a Workflow run Bot
A run Bot is built from a node's listing, but only connectors and the shell read that listing: `ctx.tools.guard` returned undefined for every other name and `ephemeralToolVerdict` answered allowed-once, so a node with tools: [] still reached message_bot, the Bot/group/capability writes, request_secret, the schedule writes, recall and the social/workflow tools. One exported constant in `tools.js` now names the 29 refused tools. The guard refuses each call with one sentence, the approval seam answers rejected for the same names (also for a subagent child, by following the parent chain through `rt.whoseAgent`), and the per-Agent mask drops them from the run Bot's list; a name the mask cannot enforce stays listed with a warn line, the guard is the refusal. The documented allowlist is the rest of the served team tools. A table over the constant proves the guard, the seam and the byte-identical state. Fixes #1445
1 parent 8f1ac12 commit ecd044e

13 files changed

Lines changed: 695 additions & 127 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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).

‎core/src/host/approvals.js‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,28 @@
33
// the group chat. A Bot's own chat keeps DSH's card; its requests are only noted, so
44
// the team view can say who is waiting.
55
import { randomUUID } from 'node:crypto'
6+
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'
67
import { OWNER, settled } from './turn-origin.js'
78

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

1012
// DSH has no standing grant (its outcomes are one-shot, #540), so the owner's "Always
1113
// allow" lives here: on the Bot or on the group, keyed by the kind of permission asked.
1214
// A sandbox escalation names its mode in DSH's reason; any other ask is keyed by tool.
1315
// A run Bot has no chat the owner can see, so an approval card would stall the
1416
// step. Connector tools are allowed only when the node's stepTools name them
1517
// (`github` covers `mcp__github__*`). A shell escalation to full access needs
16-
// `bash`, `shell` or `danger-full-access`. Other calls follow the Bot's preset
17-
// and are answered here, because nobody can tap the card.
18+
// `bash`, `shell` or `danger-full-access`. The team-reaching tools are `rejected`
19+
// whatever the step names, the same names connector-access.js refuses in its guard.
20+
// Other calls follow the Bot's preset and are answered here, because nobody can
21+
// tap the card.
1822
export function ephemeralToolVerdict(bot, toolName, reason) {
1923
if (!bot?.ephemeral) return undefined
24+
const tool = String(toolName ?? '')
25+
if (RUN_DENIED.has(tool)) return 'rejected'
2026
const tools = new Set((bot.stepTools ?? []).map(name => String(name).toLowerCase()))
2127
const grant = (name) => tools.has('*') || tools.has(String(name).toLowerCase())
22-
const tool = String(toolName ?? '')
2328
if (tool.startsWith('mcp__')) {
2429
const id = tool.split('__')[1] ?? ''
2530
return grant(tool) || (id !== '' && grant(id)) ? 'allowed-once' : 'rejected'
@@ -48,8 +53,16 @@ export function install(rt) {
4853
const sessionId = request.agent?.session?.id ?? request.agent?.id
4954
const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId)
5055
const botId = owner?.botId ?? (sessionId === undefined ? undefined : rt.chatOwners.get(sessionId))
51-
if (botId === undefined) return next()
52-
const step = ephemeralToolVerdict(rt.botOf(botId), String(request.toolName ?? ''), request.reason)
56+
// A subagent child Session started by a run Bot is in neither index. Follow the parent chain
57+
// (the same `rt.whoseAgent` secrets.js and connector-access.js use) and adopt the run Bot's
58+
// answer there; an ordinary Bot's child keeps falling through to next() exactly as before.
59+
let bot = botId === undefined ? undefined : rt.botOf(botId)
60+
if (botId === undefined) {
61+
const parent = rt.whoseAgent?.(request.agent)
62+
if (parent?.bot?.ephemeral === undefined) return next()
63+
bot = parent.bot
64+
}
65+
const step = ephemeralToolVerdict(bot, String(request.toolName ?? ''), request.reason)
5366
if (step !== undefined) return step
5467
if (standingCovers(sessionId, botId, owner?.roomId, approvalKind(String(request.toolName ?? ''), request.reason))) return 'allowed-once'
5568
const id = randomUUID()

‎core/src/host/connector-access.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
// The switches change only through `bot-connectors` on the channel, which Bots do not call.
2727

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

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

4143
export function install(rt) {
@@ -102,8 +104,14 @@ export function install(rt) {
102104
const name = exec?.name
103105
const id = connectorOfTool(name)
104106
const schedule = SCHEDULES.has(name)
105-
if (id === undefined && !schedule) return undefined
107+
const team = RUN_DENIED.has(name)
108+
if (id === undefined && !schedule && !team) return undefined
106109
const who = whoseAgent(exec.agent)
110+
// A run Bot is not a team member: a tool whose effect reaches past its own Session is refused
111+
// whatever the node's `tools` names, before the connector or the room answer. Everything that
112+
// stays (list_bots, the memory reads and writes of its own scope, …) is in
113+
// EPHEMERAL_ALLOWED_TOOLS next to it, and approvals.js answers `rejected` for the same names.
114+
if (team && who.bot?.ephemeral !== undefined) return EPHEMERAL_REFUSAL
107115
if (who.plain === true) return undefined
108116
// #1237: a room Session is one of the Bot's own Sessions (store.js indexes it as groupOwners), so
109117
// the room rule reads the same session id whoseAgent already resolved. The tip is in the reason:

‎core/src/host/hooks.js‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { faceMentions, mentionedByHandle } from './face-mentions.js'
2222
import { FEED_EVENTS, installFeed } from './flybot-feed.js'
2323
import { createPluginModel } from './plugin-model.js'
2424
import { defineTool } from './tool-schema.js'
25+
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'
2526

2627
export const FLYBOT_API_VERSION = 1
2728
export const SHARED = 'shared'
@@ -823,9 +824,29 @@ export function install(rt) {
823824
// it as known, or every other resolve lifts the mask and the next sets it again, and the
824825
// chat shows "Tool added" / "Tool removed" on each turn (#276).
825826
for (const name of current?.deny ?? []) known.add(name)
826-
const deny = [...gatedTools.values()].filter(def => known.has(def.name) && !visible(def, bot.id)).map(def => def.name).sort()
827-
const key = deny.join(',')
827+
const pluginDeny = [...gatedTools.values()].filter(def => known.has(def.name) && !visible(def, bot.id)).map(def => def.name)
828+
let deny = pluginDeny
829+
let stays = []
830+
if (bot.ephemeral !== undefined) {
831+
// A run Bot is not a team member: the team-reaching names are hidden through the same mask
832+
// the guard refuses with, so a node's own list agrees with the refusal. restrict() can name
833+
// only what the global view serves; a name the Agent itself serves stays listed — the guard,
834+
// not the list, is the refusal — and the warn line says which one it was.
835+
const runNames = EPHEMERAL_DENIED_TOOLS.filter(name => known.has(name))
836+
let global
837+
try { global = ctx.tools.schemas?.() } catch { global = undefined }
838+
if (global === undefined) {
839+
stays = runNames
840+
} else {
841+
const maskable = new Set(global.map(schema => schema.name))
842+
deny = [...pluginDeny, ...runNames.filter(name => maskable.has(name))]
843+
stays = runNames.filter(name => !maskable.has(name))
844+
}
845+
}
846+
deny = [...new Set(deny)].sort()
847+
const key = `${deny.join(',')}|${stays.join(',')}`
828848
if ((current?.key ?? '') === key) return
849+
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)
829850
try {
830851
current?.dispose?.()
831852
masks.set(agent, { key, deny, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined })

‎core/src/host/tools.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,37 @@ import { questionReply, replyText } from './parts.js'
1111
import { OTHER, settled } from './turn-origin.js'
1212
import { cleanName } from './delivery.js'
1313

14+
// A run Bot (ephemeral) is not a team member: every tool below has an effect that reaches past
15+
// the run Bot's own Session — it messages, stops or edits another Bot, edits a group, writes or
16+
// attaches a capability, asks the owner for a secret, arms or changes a schedule, reads the team
17+
// memory, sends a file, starts a workflow or reaches the browser. connector-access.js refuses
18+
// each call in `ctx.tools.guard` and approvals.js answers `rejected` for the same names, so the
19+
// two seams cannot disagree. The names span modules (this host, the harness, plugins/social,
20+
// plugins/workflow, plugins/tree); a tool added to any of them lands in neither list here and
21+
// fails `core/test/run-bot-tools.test.js` until someone classifies it.
22+
export const EPHEMERAL_DENIED_TOOLS = Object.freeze([
23+
'message_bot', 'stop_bot', 'create_bot', 'update_bot',
24+
'create_group', 'update_group', 'delete_group', 'post_to_group',
25+
'ask_user', 'save_capability', 'delete_capability', 'attach_capability', 'detach_capability',
26+
'track_tasks', 'group_relay',
27+
'request_secret', 'list_secrets',
28+
'send_message', 'schedule_create', 'schedule_update', 'schedule_delete',
29+
'message_friend_bot', 'get_friend_file', 'list_friend_bots',
30+
'define_workflow', 'run_workflow',
31+
'read_browser', 'recall', 'create_child_bot',
32+
])
33+
// The team-facing names a run Bot keeps: read-only, and each reads only what the run Bot itself
34+
// may see. `remember`/`forget` are the only writes, and `writeDenied` keeps them inside the run
35+
// Bot's own scope (never `team`, never another Bot's memory). The names a run Bot is served minus
36+
// EPHEMERAL_DENIED_TOOLS equal this list; a new tool is classified here or in the denied constant.
37+
export const EPHEMERAL_ALLOWED_TOOLS = Object.freeze([
38+
'list_bots', 'list_models', 'list_capabilities', 'bot_schedules', 'schedule_list',
39+
'read_own_chat', 'read_group_chat', 'remember', 'forget',
40+
])
41+
// One sentence that names the reason and no substitute route: the run Bot cannot become a team
42+
// member by finding another tool that does the same thing.
43+
export const EPHEMERAL_REFUSAL = 'A running Bot is not a team member.'
44+
1445
export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
1546
export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' }
1647
const BLOCKING_QUESTION_TOOL = 'ask_user_question'

0 commit comments

Comments
 (0)