From ecd044e8f36e8767233289e96e47608c8bd4fc30 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 27 Oct 2026 23:25:27 +0800 Subject: [PATCH] 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 --- .../1445-run-bot-team-tools.security.md | 1 + core/src/host/approvals.js | 23 +- core/src/host/connector-access.js | 10 +- core/src/host/hooks.js | 25 +- core/src/host/tools.js | 31 +++ core/test/run-bot-tools.test.js | 249 ++++++++++++++++++ docs/subsystems/core-host.md | 2 +- docs/subsystems/plugin-runtime.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 48 ++-- patches/ds-bot/0002-flybot-api-host.patch | 147 ++++------- .../ds-bot/files/src/host/connector-access.js | 10 +- patches/ds-bot/files/src/host/hooks.js | 25 +- .../ds-bot/files/test/run-bot-tools.test.js | 249 ++++++++++++++++++ 13 files changed, 695 insertions(+), 127 deletions(-) create mode 100644 changelog.d/1445-run-bot-team-tools.security.md create mode 100644 core/test/run-bot-tools.test.js create mode 100644 patches/ds-bot/files/test/run-bot-tools.test.js diff --git a/changelog.d/1445-run-bot-team-tools.security.md b/changelog.d/1445-run-bot-team-tools.security.md new file mode 100644 index 000000000..38a700e30 --- /dev/null +++ b/changelog.d/1445-run-bot-team-tools.security.md @@ -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). diff --git a/core/src/host/approvals.js b/core/src/host/approvals.js index 76f43ee54..3423426fd 100644 --- a/core/src/host/approvals.js +++ b/core/src/host/approvals.js @@ -3,9 +3,11 @@ // 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. @@ -13,13 +15,16 @@ const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'all // 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' @@ -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() diff --git a/core/src/host/connector-access.js b/core/src/host/connector-access.js index a046dab8a..53b6e0b45 100644 --- a/core/src/host/connector-access.js +++ b/core/src/host/connector-access.js @@ -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] @@ -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) { @@ -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: diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 06737ae84..32fe7d54a 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -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' @@ -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 }) diff --git a/core/src/host/tools.js b/core/src/host/tools.js index cd1cb05b3..d8dfbc3c0 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -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' diff --git a/core/test/run-bot-tools.test.js b/core/test/run-bot-tools.test.js new file mode 100644 index 000000000..56d6a7336 --- /dev/null +++ b/core/test/run-bot-tools.test.js @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' +import { apply } from '../index.js' +import { TEAM_TOOLS } from '../src/host/constants.js' +// A run Bot (ephemeral) is not a team member (#1445). Its node's `tools` decides connectors and +// the shell only, so the host refuses every tool whose effect reaches past its own Session — in the +// call guard, in the approval seam, and in its own tool list — and keeps only the read-only names +// plus its own memory. This file measures that the two seams agree and that nothing happened. + +import { EPHEMERAL_ALLOWED_TOOLS, EPHEMERAL_DENIED_TOOLS, EPHEMERAL_REFUSAL } from '../src/host/tools.js' +import { SCHEDULE_TOOLS } from '../src/host/connector-access.js' +import { fakeContext } from './fake-ctx.js' + +const DENIED = new Set(EPHEMERAL_DENIED_TOOLS) +const ALLOWED = new Set(EPHEMERAL_ALLOWED_TOOLS) +const sorted = names => [...names].sort() + +// The harness-side write tools the run Bot is served in a real profile. `schedule_list` is the one +// harness name that stays; the other four are write or messaging tools. +const HARNESS_TOOLS = [...SCHEDULE_TOOLS, 'send_message'] + +// The tool a source registers is `name: ''` right inside a register()/registerTool() call. +// Reading the plugin source is what makes a rename fail here instead of failing open: the core +// constant names these tools, and the plugin is the only place that knows the current spelling. +const toolRegistration = /(?:registerTool|tools\.register)\(\s*(?:defineTool\(\s*)?\{[^{}]*?name:\s*'([a-z][a-z0-9_]*)'/g +const registeredNames = path => [...readFileSync(new URL(path, import.meta.url), 'utf8').matchAll(toolRegistration)].map(match => match[1]) + +const team = () => ({ + version: 1, + mainBotIds: ['a'], + bots: { + a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, + b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 }, + c: { id: 'c', name: 'Coder', role: 'Code', sessionId: 'c', createdAt: 3 }, + }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start(config = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-run-tools-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext() + const warns = [] + ctx.logger.warn = (...args) => warns.push(args.map(String).join(' ')) + const agents = new Map() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + apply(ctx, { home, ...config }) + // A fresh Session per run Bot, the way workflows.js gets one. + let sessions = 0 + ctx.sessionController.create = async () => ({ sessionId: `run${++sessions}` }) + const agentOf = id => { + if (!agents.has(id)) agents.set(id, { id, calls: [], followup(m) { this.calls.push(m) }, steer(m) { this.calls.push(m) }, inbox: { nextStep: [], nextTurn: [] }, session: { id } }) + return agents.get(id) + } + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + // DSH asks the guards before it dispatches, and a guard's answer ends the call: the tool never + // runs. This helper is that contract, so a refusal below means no side effect happened. + const refusalOf = (name, sessionId) => ctx.guards.map(check => check({ name, agent: sessionId === undefined ? undefined : { id: sessionId } })).find(reason => reason !== undefined) + const callAsDsh = async (name, args, sessionId) => { + const why = refusalOf(name, sessionId) + if (why !== undefined) throw new Error(why) + return tool(name, args, sessionId) + } + const ask = (toolName, sessionIdOrAgent, reason = '') => ctx.listeners.get('approval/request')[0]( + { agent: typeof sessionIdOrAgent === 'object' ? sessionIdOrAgent : { session: { id: sessionIdOrAgent } }, toolName, reason }, + () => 'from-dsh', + ) + const received = id => (agents.get(id)?.calls ?? []).map(message => message.content.map(block => block.text).join('\n')) + const saved = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, fb: ctx.flybot, home, warns, tool, refusalOf, callAsDsh, ask, received, saved, stop, agentOf } +} + +const runBot = async (fb, tools) => fb.scope('flybot-workflow').bot.create({ name: 'Runner ab', role: 'Run', ephemeral: true, tools }) + +test('the issue repro is refused: a tools:[] run Bot delivers nothing and asks nothing', async (t) => { + const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() + t.after(stop) + const id = await runBot(fb, []) + assert.deepEqual(saved().bots[id].stepTools, undefined, 'tools: [] is the case read as "nothing granted"') + + const before = readFileSync(join(home, 'state.json'), 'utf8') + await assert.rejects(() => callAsDsh('message_bot', { to: 'Writer', message: 'hi' }, id), new RegExp(EPHEMERAL_REFUSAL)) + await assert.rejects(() => callAsDsh('message_bot', { to: 'Writer', message: 'hi', reply_to: 'user' }, id), new RegExp(EPHEMERAL_REFUSAL)) + assert.deepEqual(received('b'), [], 'nothing was delivered to Writer') + assert.equal(await ask('message_bot', id), 'rejected', 'the approval seam agrees with the call guard') + assert.equal(await ask('message_bot', id, 'why not'), 'rejected') + // The harness's own question and secret tools are the same answer, so no card or request appears. + assert.match(await refusalOf('ask_user', id), /not a team member/) + assert.match(await refusalOf('request_secret', id), /not a team member/) + const state = saved() + assert.deepEqual(state.questions, {}, 'no question card was opened') + assert.equal(state.secretRequests, undefined, 'no secret request was recorded') + assert.equal(readFileSync(join(home, 'state.json'), 'utf8'), before, 'the whole attempt wrote nothing') +}) + +test('the constant refuses every name at the guard and the approval seam, with no side effect', async (t) => { + const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() + t.after(stop) + const id = await runBot(fb, []) + assert.equal(EPHEMERAL_DENIED_TOOLS.length, 29) + assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 29, 'no duplicate name') + assert.equal(new Set(EPHEMERAL_ALLOWED_TOOLS).size, EPHEMERAL_ALLOWED_TOOLS.length) + assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => ALLOWED.has(name)), [], 'a name is in one list only') + + const before = readFileSync(join(home, 'state.json'), 'utf8') + const args = { + message_bot: { to: 'Writer', message: 'hi' }, + stop_bot: { bot: 'Writer' }, + create_bot: { name: 'Made ab' }, + update_bot: { bot: 'Writer', role: 'New' }, + create_group: { name: 'Desk', members: ['Chief'] }, + update_group: { group: 'Desk', notice: 'x' }, + delete_group: { group: 'Desk' }, + post_to_group: { group: 'Desk', message: 'hi' }, + ask_user: { question: 'Go?', options: [{ label: 'Yes' }] }, + request_secret: { name: 'TOKEN', purpose: 'x' }, + message_friend_bot: { to: '@bob.home', message: 'hi' }, + get_friend_file: { id: 'att_x' }, + define_workflow: { name: 'w', nodes: [], edges: [] }, + run_workflow: { workflow: 'w', brief: 'b' }, + schedule_create: { prompt: 'x', title: 't' }, + } + for (const name of EPHEMERAL_DENIED_TOOLS) { + await assert.rejects(() => callAsDsh(name, args[name] ?? {}, id), new RegExp(EPHEMERAL_REFUSAL), name) + assert.equal(await ask(name, id), 'rejected', name) + } + assert.deepEqual(received('b'), [], 'no message ran') + const state = saved() + assert.deepEqual(state.questions, {}, 'no ask_user card') + assert.equal(state.secretRequests, undefined, 'no request_secret entry') + assert.deepEqual(Object.keys(state.rooms), [], 'no group was written') + assert.equal(readFileSync(join(home, 'state.json'), 'utf8'), before, 'the block wrote nothing') + + // Ordinary Bots keep the same tools, and a friend's Bot is unchanged too. + for (const name of ['message_bot', 'create_group', 'request_secret', 'recall']) assert.equal(refusalOf(name, 'a'), undefined, name) +}) + +test('a subagent child of a run Bot answers rejected; an ordinary Bot\'s child still falls through', async (t) => { + const { fb, ctx, ask, stop } = await start() + t.after(stop) + const id = await runBot(fb, []) + const child = { id: 'child-run', session: { id: 'child-run', header: { parentSession: id } } } + assert.equal(await ask('message_bot', child), 'rejected', 'the parent chain reaches the run Bot') + assert.equal(await ask('create_group', child), 'rejected') + assert.equal(await ask('read', child), 'allowed-once', 'a tool the run Bot keeps is still fine') + // An ordinary Bot's child is not adopted: its request still goes to DSH's own answerer. + const ordinary = { id: 'child-writer', session: { id: 'child-writer', header: { parentSession: 'b' } } } + const seen = [] + const verdict = ctx.listeners.get('approval/request')[0]({ agent: ordinary, toolName: 'message_bot' }, () => { seen.push('next'); return 'from-dsh' }) + assert.equal(await verdict, 'from-dsh') + assert.deepEqual(seen, ['next']) +}) + +test('the constant is a subset of the served names; served minus it is the documented allowlist', async (t) => { + const { ctx, stop } = await start({ browser: true }) + t.after(stop) + // The core half is measured, not listed: with the Desktop browser flag the registered tools are + // exactly TEAM_TOOLS (agents.test.js holds the same equality), so a core tool added with no + // classification shows up in `SERVED` and fails the equality below. + assert.deepEqual(sorted(ctx.registeredTools.keys()), sorted(TEAM_TOOLS)) + // The plugin half is read from the source, so a rename there fails here instead of failing open. + const social = registeredNames('../../plugins/social/src/social.js') + const workflow = registeredNames('../../plugins/workflow/index.js') + const tree = registeredNames('../../plugins/tree/src/tools.js') + assert.deepEqual(sorted(social), ['get_friend_file', 'list_friend_bots', 'message_friend_bot']) + assert.deepEqual(sorted(workflow), ['define_workflow', 'run_workflow']) + assert.deepEqual(sorted(tree), ['create_child_bot', 'create_team', 'find_bots', 'list_children', 'move_bot']) + // The tree's four other tools are behind `visibleTo: inTree` and reach no run Bot; create_child_bot + // is in the constant anyway so its refusal reads like the rest. + const SERVED = new Set([...ctx.registeredTools.keys(), ...HARNESS_TOOLS, ...social, ...workflow, 'create_child_bot']) + assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => !SERVED.has(name)), [], 'every refused name is served') + assert.deepEqual(sorted([...SERVED].filter(name => !DENIED.has(name))), sorted(EPHEMERAL_ALLOWED_TOOLS), 'served minus refused is the allowlist') + for (const gated of tree.filter(name => name !== 'create_child_bot')) assert.equal(DENIED.has(gated) || ALLOWED.has(gated), false, gated) +}) + +test('the run Bot\'s own tool list loses the refused names while a team Bot keeps message_bot', async (t) => { + const { ctx, fb, stop } = await start({ browser: true }) + t.after(stop) + const id = await runBot(fb, []) + // What the profile serves globally, and what one Agent serves itself, as the real registry answers. + const served = new Set([...ctx.registeredTools.keys(), ...HARNESS_TOOLS, ...registeredNames('../../plugins/social/src/social.js'), ...registeredNames('../../plugins/workflow/index.js'), 'create_child_bot']) + const own = [] + const masks = new Map() + const hiddenOf = sessionId => new Set((masks.get(sessionId) ?? []).flatMap(mask => [...mask])) + ctx.tools.schemas = (agent) => { + const base = agent === undefined ? [...served] : [...served, ...own] + const hidden = agent === undefined ? new Set() : hiddenOf(agent.session?.id ?? agent.id) + return base.filter(name => !hidden.has(name)).map(name => ({ name })) + } + const restrict = sessionId => ({ deny }) => { + const unknown = deny.filter(name => !served.has(name)) + if (unknown.length > 0) throw new Error(`tools.restrict() names unknown tools ${unknown.join(', ')}`) + const mask = new Set(deny) + masks.set(sessionId, [...(masks.get(sessionId) ?? []), mask]) + return () => masks.set(sessionId, (masks.get(sessionId) ?? []).filter(entry => entry !== mask)) + } + const runAgent = { id, session: { id }, ctx: { tools: { restrict: restrict(id) }, effect: execute => execute() } } + const teamAgent = { id: 'b', session: { id: 'b' }, ctx: { tools: { restrict: restrict('b') }, effect: execute => execute() } } + const until = async (ok) => { for (let i = 0; i < 400 && !ok(); i += 1) await delay(5); assert.ok(ok(), 'the mask landed') } + ctx.emitArgs('agent/created', { agent: runAgent }) + await until(() => ctx.tools.schemas(runAgent).every(schema => !DENIED.has(schema.name)) && ctx.tools.schemas(runAgent).length > 0) + ctx.emitArgs('agent/created', { agent: teamAgent }) + await delay(30) + const runNames = new Set(ctx.tools.schemas(runAgent).map(schema => schema.name)) + assert.deepEqual([...runNames].filter(name => DENIED.has(name)), [], 'the run Bot\'s list carries no refused name') + assert.equal(runNames.has('message_bot'), false) + assert.deepEqual(sorted([...runNames]), sorted(EPHEMERAL_ALLOWED_TOOLS), 'and keeps exactly the allowlist') + assert.equal(ctx.tools.schemas(teamAgent).some(schema => schema.name === 'message_bot'), true, 'a team Bot keeps message_bot') +}) + +test('a refused name the mask cannot enforce stays listed with a warning; the guard still refuses it', async (t) => { + const { ctx, fb, warns, refusalOf, stop } = await start({ browser: true }) + t.after(stop) + const id = await runBot(fb, []) + // `list_secrets` is served to the Agent but not in the profile's global list, the one shape + // restrict() cannot name (#1271). It stays listed; the warn line says so; the guard is the refusal. + const served = new Set([...ctx.registeredTools.keys()].filter(name => name !== 'list_secrets')) + const own = ['list_secrets'] + const masks = new Map() + const hiddenOf = sessionId => new Set((masks.get(sessionId) ?? []).flatMap(mask => [...mask])) + ctx.tools.schemas = (agent) => { + const base = agent === undefined ? [...served] : [...served, ...own] + const hidden = agent === undefined ? new Set() : hiddenOf(agent.session?.id ?? agent.id) + return base.filter(name => !hidden.has(name)).map(name => ({ name })) + } + const runAgent = { + id, session: { id }, + ctx: { tools: { restrict: ({ deny }) => { const mask = new Set(deny); masks.set(id, [...(masks.get(id) ?? []), mask]); return () => masks.set(id, (masks.get(id) ?? []).filter(entry => entry !== mask)) } }, effect: execute => execute() }, + } + ctx.emitArgs('agent/created', { agent: runAgent }) + for (let i = 0; i < 400 && !warns.some(line => line.includes('list_secrets')); i += 1) await delay(5) + assert.ok(warns.some(line => line.includes('list_secrets') && line.includes('stay listed')), 'the warn line names the name it could not remove') + assert.equal(ctx.tools.schemas(runAgent).some(schema => schema.name === 'list_secrets'), true, 'it stays in the list') + assert.match(await refusalOf('list_secrets', id), /not a team member/, 'the guard refuses it anyway') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a722fa5a6..1e0c5f664 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -333,7 +333,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`. - Silent creation (#645): `createBot` and `create-room` send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: `new-chat.js` sends it to the new Bot as one user turn and the card carries no `brief`; `roster.js` turns a `brief` into `Why you exist: …` instructions only when a Bot created the Bot (`create_bot`, a tree child), where the brief is a parent's context. The `kickoff` role, the `[Bot team setup]` text and the group-created cue are gone (an older transcript's kickoff line is still skipped by `historyEntry`), and `create_bot` answers that the new Bot starts no turn. Covered by `core/test/roster.test.js`, `core/test/rounds.test.js` and `core/test/new-chat.test.js`. - Every new Bot is announced (`bot.created`) through the `rt.createBot` wrapper, including the first Main Bot `bootstrap` creates on the first `state` request and a `duplicate-bot` copy. Creating those through the module-local `createBot` used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. A run Bot is the exception: `createBot` stores `hidden` and `ephemeral: { plugin }` before the first save, and the wrapper does not fire `bot.created` for it. Covered by `core/test/flybot.test.js`. -- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. It is not a permissionless shell: `flybot.bot.create` may pass `tools` and `permission` for an ephemeral Bot only. `stepTools` is what connector-access and the approval listener read. `github` grants `mcp__github__*`; a connector the step did not name is hidden and a call is refused (`not granted to this run`). A hidden run Bot cannot show an approval card, so a granted call is `allowed-once` and an ungranted connector or an ungranted full-access shell escalation is `rejected`. Covered by `core/test/flybot.test.js` and `core/test/connector-access.test.js`. +- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. It is not a permissionless shell: `flybot.bot.create` may pass `tools` and `permission` for an ephemeral Bot only. `stepTools` is what connector-access and the approval listener read. `github` grants `mcp__github__*`; a connector the step did not name is hidden and a call is refused (`not granted to this run`). A hidden run Bot cannot show an approval card, so a granted call is `allowed-once` and an ungranted connector or an ungranted full-access shell escalation is `rejected`. **It is not a team member either (#1445):** a node's `tools` covers connectors and the shell, not the team surface, so `EPHEMERAL_DENIED_TOOLS` in `tools.js` names every tool whose effect reaches past the run Bot's own Session — `message_bot`/`stop_bot`/`create_bot`/`update_bot`, groups and posts, `ask_user`, the capability writes, `request_secret`/`list_secrets`, the schedule writes, `send_message`, the social and workflow tools, `read_browser`, `recall` (its default scope reads the team memory) and `create_child_bot`. `connector-access.js` refuses each call with `A running Bot is not a team member.`, `approvals.js` answers `rejected` for the same names (also for a subagent child Session, by following the parent chain through `rt.whoseAgent`), and the run Bot's own list loses them through `restrict({ deny })`; a name the profile serves but the mask cannot enforce stays listed with a warn line, and the guard is what refuses it. `EPHEMERAL_ALLOWED_TOOLS` is the rest: the read-only tools (`list_bots`, `list_models`, `list_capabilities`, `bot_schedules`, `schedule_list`, `read_own_chat`, `read_group_chat`) and `remember`/`forget`, which `writeDenied` keeps inside the run Bot's own scope. Ordinary Bots, friend's Bots and group rooms are unchanged, and `plugins/workflow/**` is not edited: the node's `tools` still decides connectors and the shell. Covered by `core/test/flybot.test.js`, `core/test/connector-access.test.js` and `core/test/run-bot-tools.test.js`. - Group Session models (#289, #1127): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. A settled record (`appliedModel` matches and `modelStale` is unset) still re-selects a group Session whose last request used another provider or model, on that apply and before the Session's next turn; the Bot's own chat is left as recorded. A Session whose route cannot be read is left alone, so a settled Bot is not selected again on every refresh. A fresh part takes the Bot's model, not the previous request's route. Covered by `core/test/models.test.js`, `core/test/room-participant.test.js` and `core/test/fresh-part-model.test.js`. ### Face-to-face room lead (#476) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index ba6e0f5f2..446e6e999 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -52,7 +52,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 3. Types in `flybot.d.ts` stay in sync with hooks/slots. A tool keeps one schema from `flybot.tools.register` to the model, and that schema names `properties` and `required` on every object node: the wrapper in `core/src/host/tool-schema.js` completes what DSH's compiler leaves out (a strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request), so a plugin's parameter map needs no such member of its own. 4. Plugins report a host-visible failure with `flybot.warn` (Settings → Feedback / `diagnostics.jsonl`). They must not depend on `ctx.logger`: DSH may export none, and `flybot.warn` delegates to `rt.warn`. - A tool whose `visibleTo` says no is taken out of that Bot's list with `agent.ctx.tools.restrict({ deny })` (`hidePluginTools`). DSH's `tools.schemas(agent)` lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows `Tool added` / `Tool removed` on each turn (#276). The mask is recomputed when the Agent is created, on every `resolveAgent` (delivery), and when a turn starts in the Session (a `session/event` `turn/start`), so a Bot a plugin adopts only after its Agent was created — the tree taking a Main Bot made after the server started — gets its tools on the next turn, with no restart (#594). The names of these gated tools go to the client as the roster's `pluginTools` (`rt.pluginToolNames`), and the client's `ContextCell` hides `Tool added` / `Tool removed` rows for them in every chat, so the first mask leaves no line either (#419). + A tool whose `visibleTo` says no is taken out of that Bot's list with `agent.ctx.tools.restrict({ deny })` (`hidePluginTools`). DSH's `tools.schemas(agent)` lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows `Tool added` / `Tool removed` on each turn (#276). The mask is recomputed when the Agent is created, on every `resolveAgent` (delivery), and when a turn starts in the Session (a `session/event` `turn/start`), so a Bot a plugin adopts only after its Agent was created — the tree taking a Main Bot made after the server started — gets its tools on the next turn, with no restart (#594). The names of these gated tools go to the client as the roster's `pluginTools` (`rt.pluginToolNames`), and the client's `ContextCell` hides `Tool added` / `Tool removed` rows for them in every chat, so the first mask leaves no line either (#419). The same mask also drops the names a run Bot may not call: for an `ephemeral` Bot the deny list adds `EPHEMERAL_DENIED_TOOLS` from `tools.js` (the team-reaching tools `connector-access.js` refuses), naming only what the global `tools.schemas()` serves; a refused name the Agent itself serves stays listed, with one warn line that names it, and the guard is still the refusal (#1445). 5. Reach (#90): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read grants through `flybot.bot.hasGrant` / `hasAnyGrant`, `BotView.granted`, `flybot.grants` and the `bot.grants` event. A group's mention receipt (`mentionReceipt`) turns a not-shared refusal (`cannot message their Bots`, #273) into its short invite-or-add hint. 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. An `allow` verdict may say `autoSent: true` (with `recipient`) when it skips a hold only because the owner turned Auto-send on; the host then marks an auto-reply's turn Auto-sent (#417). It confers nothing. A remote turn that died is not that hold (#1068): `replyRemote` sends the host's fixed sentence with `via: 'turn-failed'` and `meta.notice: 'turn-failed'` (`failure: true`), and the social plugin allows only those two sentences. A model reply stays `auto-reply`. A `hold` verdict may carry `recipient`, who gets the message as the owner knows them (a person's name, trimmed, at most 120 characters); the record keeps it as `recipientName` and the cards name it (#418). A Bot's held sends are also visible to Bot coordination: `list_bots` tags the waiting Bot with `N to approve` (a count only, `src/host/tools.js`, #587). A `flybot.approvals` entry with `inline: true` and a `policy` is not an overlay card: it draws the decision on a held reply's own bubble in the chat (`held-reply.js`, props `{ held, decide, call, actions }`), and the overlay skips it. Approval claims (`claimApprovals`, `chatOpened`) live in `approval-claims.js` and are re-exported by `flybot-slots.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1f0671aa0..73efcedb1 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2603,28 +2603,30 @@ diff --git a/src/host/agents.js b/src/host/agents.js diff --git a/src/host/approvals.js b/src/host/approvals.js --- a/src/host/approvals.js +++ b/src/host/approvals.js -@@ -3,8 +3,40 @@ - // 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' +@@ -6,6 +6,43 @@ ++import { EPHEMERAL_DENIED_TOOLS } from './tools.js' +import { OWNER, settled } from './turn-origin.js' -const OUTCOMES = { allow: 'allowed-once', reject: 'rejected' } +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' @@ -2642,23 +2644,28 @@ diff --git a/src/host/approvals.js b/src/host/approvals.js + const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1] + return mode ? `${tool} · ${mode}` : String(tool) +} - ++ export function install(rt) { const { ctx } = rt -@@ -17,6 +17,9 @@ export function install(rt) { - 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) + /** @type {Mapvoid}>} */ +@@ -19,4 +19,15 @@ +- if (botId === undefined) return next() ++ // 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() const entry = { id, -@@ -47,14 +47,82 @@ export function install(rt) { - }) - }, { prepend: true }) - +@@ -50,11 +50,79 @@ + // A group grant covers the owner's Bots working in that group: the Bot it was granted + // from and any other Bot of the same owner. Before anyone signs in there is one person, + // and a Bot made then carries no owner, so every local Bot is theirs. @@ -2739,10 +2746,7 @@ diff --git a/src/host/approvals.js b/src/host/approvals.js entry.answer(outcome) } -@@ -65,5 +65,5 @@ export function install(rt) { - } - } - +@@ -68,2 +68,2 @@ - Object.assign(rt, { approvalsView, waitsForApproval, answerApproval, cancelApprovals }) + Object.assign(rt, { approvalsView, waitsForApproval, answerApproval, cancelApprovals, removeStandingApproval }) } diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 8890e6c83..e6eebd61b 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -993,10 +993,7 @@ diff --git a/src/host/store.js b/src/host/store.js diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js -@@ -1,9 +1,15 @@ - // Team tools: the roster, bot-to-bot messages, group chat settings and posts, and the - // question card. Group rounds and reading chats are in groups.js. - import { randomUUID } from 'node:crypto' +@@ -4,7 +4,44 @@ -import { defineTool } from '@deepseek-ai/dsh-tools' -import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js' +import { columnOf, compareForWork } from '../shared/task-board.js' @@ -1009,12 +1006,41 @@ diff --git a/src/host/tools.js b/src/host/tools.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' } -@@ -18,6 +18,21 @@ const WORK_DIR_PARAM = { type: 'string', description: 'Full path of the folder t - export function install(rt) { - const { ctx, state } = rt - + const BLOCKING_QUESTION_TOOL = 'ask_user_question' +@@ -21,3 +21,18 @@ + // The tool speaks NAME=value because a free-form object parameter is not expressible in the + // schema DSH compiles; the record keeps the typed map (#198). + const externalOf = (input) => { @@ -1033,10 +1059,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const callerBot = (exec) => rt.botOf(rt.selfOf(exec.agent?.id)) const callerRoom = (exec) => { const roomId = rt.groupOwners.get(exec.agent?.id)?.roomId -@@ -27,39 +27,110 @@ export function install(rt) { - const roomLine = (room, self) => { - const members = room.members.map(rt.botOf).filter(Boolean) - .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) +@@ -30,36 +30,107 @@ + const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) + .map(p => `${String(p.displayName ?? p.handle).replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 60)} (${String(p.ownerName ?? p.ownerHome ?? 'friend').replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 40)}'s Bot)`) const parts = [ @@ -1151,10 +1174,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js ].join('\n') }, })) -@@ -81,12 +81,18 @@ export function install(rt) { - - // A tool that throws shows as failed in the chat, instead of as sent or stopped. - const refuse = (message) => { throw new Error(message) } +@@ -84,9 +84,15 @@ + // A run Bot keeps its name (findBot still sees it) but no team tool can address it (#1373). + const teamBot = bot => bot !== undefined && bot.remote === undefined && bot.ephemeral === undefined + const findTeamBot = (name) => { @@ -1171,10 +1191,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' }, reply_to: { type: 'string', -@@ -104,8 +104,18 @@ export function install(rt) { - await rt.load() - const sender = callerBot(exec) - if (sender === undefined) return 'Only Bots on the team can use message_bot.' +@@ -107,5 +107,15 @@ - const target = rt.findBot(args.to) - if (target === undefined) return `No Bot named ${args.to}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}` + const target = findTeamBot(args.to) @@ -1192,10 +1209,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) return 'You cannot message yourself.' const turn = rt.live.get(exec.agent?.id) const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0)) -@@ -118,7 +118,11 @@ export function install(rt) { - const toUser = args.reply_to === 'user' - const working = rt.live.get(chat)?.turn !== undefined - const steer = args.when !== 'queue' && !toUser +@@ -121,4 +121,8 @@ - await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } : callerRoom(exec)) }) + const exchangeId = await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, origin: rt.agentOrigin?.(exec.agent), ...(toUser ? { audience: 'user' } : callerRoom(exec)) }) + // FlyBot send policies (flybot.js) may hold the message for the owner or refuse it. @@ -1205,10 +1219,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const sent = toUser ? `Delivered to ${target.name}. It will answer the user in its own chat; nothing comes back to you, so point the user to ${target.name}'s chat instead of relaying.` : working && steer -@@ -152,8 +152,8 @@ export function install(rt) { - await rt.load() - const sender = callerBot(exec) - if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.') +@@ -155,5 +155,5 @@ - const target = rt.findBot(args.bot) - if (target === undefined) refuse(`No Bot named ${args.bot}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`) + const target = findTeamBot(args.bot) @@ -1216,10 +1227,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) refuse('You cannot stop yourself.') const { running } = activeSessions(target.id) if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`) -@@ -162,21 +162,32 @@ export function install(rt) { - agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' }) - } - const why = String(args.reason ?? '').trim() || 'Stop what you are doing; the previous turn was ended on purpose. Acknowledge briefly.' +@@ -165,18 +165,29 @@ - await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped', ...callerRoom(exec) }) + await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped', origin: rt.agentOrigin?.(exec.agent), ...callerRoom(exec) }) return `Stopped ${target.name} in ${running.map(whereOf).join(' and ')}. It is told why in a new turn, whose reply comes back to you.` @@ -1252,10 +1260,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js }, output, async execute(args, exec) { -@@ -186,8 +186,8 @@ export function install(rt) { - const workDir = args.work_dir ? await rt.cleanWorkDir(args.work_dir) : undefined - const name = String(args.name ?? '').trim() - await rt.approveFolder(exec, { path: workDir, who: { en: `the new Bot ${name}`, zh: `新 Bot ${name} ` } }) +@@ -189,5 +189,5 @@ - const bot = await rt.createBot({ ...args, workDir, createdBy: rt.selfOf(exec.agent.id) }) - return `Created ${bot.name}${workDir ? `, working in ${workDir}` : ''}. It is greeting the user now.` + const bot = await rt.createBot({ ...args, workDir, createdBy: rt.selfOf(exec.agent.id), external: externalOf(args.external) }) @@ -1263,19 +1268,13 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (error) { return `Could not create the Bot: ${error.message}` } -@@ -212,7 +212,7 @@ export function install(rt) { - const self = rt.selfOf(exec.agent?.id) - const caller = rt.botOf(self) - if (caller === undefined) return 'Only Bots on the team can use update_bot.' +@@ -215,4 +215,4 @@ - const bot = args.bot ? rt.findBot(args.bot) : caller + const bot = args.bot ? findTeamBot(args.bot) : caller if (bot === undefined) return `No Bot named ${args.bot}.` if (bot.id !== caller.id && !rt.isMain(self)) return 'Only a Main Bot can change another Bot. Leave out bot to change yourself, or ask a Main Bot with message_bot.' try { -@@ -251,13 +251,13 @@ export function install(rt) { - const bots = [] - const unknown = [] - for (const ref of nameList(refs)) { +@@ -254,10 +254,10 @@ - const bot = rt.findBot(ref) + const bot = findTeamBot(ref) if (bot === undefined) unknown.push(ref) @@ -1288,36 +1287,24 @@ diff --git a/src/host/tools.js b/src/host/tools.js const groupNames = self => visibleRooms(self).map(room => `"${room.name}"`).join(', ') || 'none' const pickRoom = (ref, self) => { const matches = rt.findRooms(ref).filter(room => visibleRooms(self).includes(room)) -@@ -283,7 +283,7 @@ export function install(rt) { - if (!rt.isMain(self)) return 'Only a Main Bot can create group chats. Ask a Main Bot instead.' - const { bots, unknown } = resolveBots(args.members) - if (unknown.length > 0) return `No Bot named ${unknown.join(', ')}. Team: ${teamNames()}` +@@ -286,4 +286,4 @@ - const admin = args.admin ? rt.findBot(args.admin) : rt.botOf(self) + const admin = args.admin ? findTeamBot(args.admin) : rt.botOf(self) if (admin === undefined) return `No Bot named ${args.admin}. Team: ${teamNames()}` const members = [...new Set([self, ...bots.map(bot => bot.id), admin.id])] try { -@@ -297,7 +297,7 @@ export function install(rt) { - - ctx.tools.register(defineTool({ - name: 'update_group', +@@ -300,4 +300,4 @@ - description: 'Change a group chat: rename it, set its notice, add or remove members, change its admin or reply mode. Main Bots may change any group; a group\'s admin may change its own group.', + description: 'Change a group chat: rename it, set its notice, add or remove members, change its admin, its task lead, or its reply mode. Main Bots may change any group; a group\'s admin may change its own group.', parameters: { group: GROUP_REF, name: { type: 'string', description: 'New group name; an empty string names it after its members again' }, -@@ -305,6 +305,7 @@ export function install(rt) { - add_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to add' }, - remove_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to remove' }, - admin: { type: 'string', description: 'Member to make the admin; "none" leaves the group without one (Main Bots only)' }, +@@ -308,3 +308,4 @@ + task_lead: { type: 'string', description: 'Member that divides this room\'s task board. "none" leaves that to the admin. A face-to-face room has no board.' }, mode: MODE_PARAM, work_dir: { ...WORK_DIR_PARAM, description: `${WORK_DIR_PARAM.description}. Every member works there in this group; an empty string lets each member work in its own folder again (Main Bots only)` }, }, -@@ -325,10 +325,18 @@ export function install(rt) { - if (args.admin !== undefined && args.admin !== '') { - if (/^(none|nobody|null|无|没有)$/i.test(String(args.admin).trim())) admin = null - else { +@@ -328,7 +328,15 @@ - admin = rt.findBot(args.admin)?.id + admin = findTeamBot(args.admin)?.id if (admin === undefined) return `No Bot named ${args.admin}. Team: ${teamNames()}` @@ -1334,18 +1321,12 @@ diff --git a/src/host/tools.js b/src/host/tools.js try { const workDir = moving ? await rt.cleanWorkDir(args.work_dir) : undefined // A Bot that joins a group with a folder works there from its first turn. -@@ -345,6 +345,7 @@ export function install(rt) { - notice: args.notice, - mode: args.mode, - admin, +@@ -348,3 +348,4 @@ + ...(taskLead !== undefined ? { taskLead } : {}), add: add.bots.map(bot => bot.id), remove: remove.bots.map(bot => bot.id), ...(moving ? { workDir: workDir ?? '' } : {}), -@@ -395,12 +395,11 @@ export function install(rt) { - if ((info?.hop ?? 0) >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.' - const text = String(args.message ?? '').trim() - if (text === '') return 'Write a message to post.' +@@ -398,9 +398,8 @@ - if ((room.botRounds ?? 0) >= ROOM_BOT_ROUNDS) return `Bots already started ${ROOM_BOT_ROUNDS} rounds in "${room.name}" since the user last spoke there. Wait for the user.` - room.botRounds = (room.botRounds ?? 0) + 1 - await rt.save() @@ -1358,10 +1339,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (failure) { return `Could not post in "${room.name}": ${failure.message}` } -@@ -445,6 +445,10 @@ export function install(rt) { - .filter(option => option.label !== '') - .slice(0, 6) - if (options.length === 0) return 'Give at least one option.' +@@ -448,3 +448,7 @@ + // Work handed out from a group (turns.js): the user handed it out there, so a question + // about it is asked there too, never in the Bot's own chat (#550). + const handed = rt.handedFromGroup(id) @@ -1369,10 +1347,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the // composer dock, which takes questions without a call. const turn = rt.live.get(id)?.turn -@@ -465,6 +465,77 @@ export function install(rt) { - }, - })) - +@@ -468,3 +468,74 @@ + // The card sits in the group's composer dock, named for the Bot that asks. One card per group: + // while another Bot's waits there, this one asks in its reply, which goes back to the group. + async function askInGroup({ room, info }, args, options, exec) { @@ -1447,37 +1422,25 @@ diff --git a/src/host/tools.js b/src/host/tools.js // dsh's own question tool holds the turn open until the user answers, and every // message from another Bot or a group round queues behind it. Bot Sessions ask // through ask_user instead, so the tool is taken out of their tool list. -@@ -525,7 +525,7 @@ export function install(rt) { - name: 'save_capability', - description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.', - parameters: { +@@ -528,4 +528,4 @@ - name: { type: 'string', required: true, description: 'Lowercase letters, digits, and single hyphens, up to 32 characters; "github" is reserved' }, + name: { type: 'string', required: true, description: 'Lowercase letters, digits, and single hyphens, up to 32 characters; "github" and "composio" are reserved' }, kind: { type: 'string', required: true, enum: ['mcp', 'skill', 'pack'] }, description: { type: 'string', description: 'What this capability provides' }, transport: { type: 'string', enum: ['stdio', 'streamable-http'], description: 'How to reach an MCP server: a local command or an HTTP URL' }, -@@ -545,7 +545,7 @@ export function install(rt) { - try { - const item = await rt.capabilitySave(args, { botId: self }) - const approval = item.kind === 'mcp' && item.transport === 'stdio' && !item.approved +@@ -548,4 +548,4 @@ - ? ' The user must allow this MCP server in DS Bot settings before it can work.' + ? ' The user must allow this MCP server in FlyBot settings before it can work.' : '' return `Saved ${item.name}. Bots with it attached use the change from their next message.${approval}` } catch (error) { return error.message } -@@ -587,7 +587,7 @@ export function install(rt) { - : attached ? [attached] : [] - const approvals = members - .filter(item => item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true) +@@ -590,4 +590,4 @@ - .map(item => ` The user must allow ${item.name} in DS Bot settings before it can work.`) + .map(item => ` The user must allow ${item.name} in FlyBot settings before it can work.`) .join('') return `${verb} ${args.name} ${args.bot ? `to ${result.bot}` : 'to yourself'}. Changes apply from ${result.bot}'s next message.${endpoint === 'attach' ? approvals : ''}` } catch (error) { return error.message } -@@ -595,5 +595,121 @@ export function install(rt) { - })) - } - +@@ -598,2 +598,118 @@ + const actionable = task => task.status === 'open' || task.status === 'claimed' || task.status === 'working' + function formatTasks(tasks, { inGroup, withNext }) { + const ordered = [...tasks].sort(compareForWork) diff --git a/patches/ds-bot/files/src/host/connector-access.js b/patches/ds-bot/files/src/host/connector-access.js index a046dab8a..53b6e0b45 100644 --- a/patches/ds-bot/files/src/host/connector-access.js +++ b/patches/ds-bot/files/src/host/connector-access.js @@ -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] @@ -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) { @@ -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: diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 06737ae84..32fe7d54a 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -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' @@ -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 }) diff --git a/patches/ds-bot/files/test/run-bot-tools.test.js b/patches/ds-bot/files/test/run-bot-tools.test.js new file mode 100644 index 000000000..56d6a7336 --- /dev/null +++ b/patches/ds-bot/files/test/run-bot-tools.test.js @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' +import { apply } from '../index.js' +import { TEAM_TOOLS } from '../src/host/constants.js' +// A run Bot (ephemeral) is not a team member (#1445). Its node's `tools` decides connectors and +// the shell only, so the host refuses every tool whose effect reaches past its own Session — in the +// call guard, in the approval seam, and in its own tool list — and keeps only the read-only names +// plus its own memory. This file measures that the two seams agree and that nothing happened. + +import { EPHEMERAL_ALLOWED_TOOLS, EPHEMERAL_DENIED_TOOLS, EPHEMERAL_REFUSAL } from '../src/host/tools.js' +import { SCHEDULE_TOOLS } from '../src/host/connector-access.js' +import { fakeContext } from './fake-ctx.js' + +const DENIED = new Set(EPHEMERAL_DENIED_TOOLS) +const ALLOWED = new Set(EPHEMERAL_ALLOWED_TOOLS) +const sorted = names => [...names].sort() + +// The harness-side write tools the run Bot is served in a real profile. `schedule_list` is the one +// harness name that stays; the other four are write or messaging tools. +const HARNESS_TOOLS = [...SCHEDULE_TOOLS, 'send_message'] + +// The tool a source registers is `name: ''` right inside a register()/registerTool() call. +// Reading the plugin source is what makes a rename fail here instead of failing open: the core +// constant names these tools, and the plugin is the only place that knows the current spelling. +const toolRegistration = /(?:registerTool|tools\.register)\(\s*(?:defineTool\(\s*)?\{[^{}]*?name:\s*'([a-z][a-z0-9_]*)'/g +const registeredNames = path => [...readFileSync(new URL(path, import.meta.url), 'utf8').matchAll(toolRegistration)].map(match => match[1]) + +const team = () => ({ + version: 1, + mainBotIds: ['a'], + bots: { + a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, + b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 }, + c: { id: 'c', name: 'Coder', role: 'Code', sessionId: 'c', createdAt: 3 }, + }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start(config = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-run-tools-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext() + const warns = [] + ctx.logger.warn = (...args) => warns.push(args.map(String).join(' ')) + const agents = new Map() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + apply(ctx, { home, ...config }) + // A fresh Session per run Bot, the way workflows.js gets one. + let sessions = 0 + ctx.sessionController.create = async () => ({ sessionId: `run${++sessions}` }) + const agentOf = id => { + if (!agents.has(id)) agents.set(id, { id, calls: [], followup(m) { this.calls.push(m) }, steer(m) { this.calls.push(m) }, inbox: { nextStep: [], nextTurn: [] }, session: { id } }) + return agents.get(id) + } + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + // DSH asks the guards before it dispatches, and a guard's answer ends the call: the tool never + // runs. This helper is that contract, so a refusal below means no side effect happened. + const refusalOf = (name, sessionId) => ctx.guards.map(check => check({ name, agent: sessionId === undefined ? undefined : { id: sessionId } })).find(reason => reason !== undefined) + const callAsDsh = async (name, args, sessionId) => { + const why = refusalOf(name, sessionId) + if (why !== undefined) throw new Error(why) + return tool(name, args, sessionId) + } + const ask = (toolName, sessionIdOrAgent, reason = '') => ctx.listeners.get('approval/request')[0]( + { agent: typeof sessionIdOrAgent === 'object' ? sessionIdOrAgent : { session: { id: sessionIdOrAgent } }, toolName, reason }, + () => 'from-dsh', + ) + const received = id => (agents.get(id)?.calls ?? []).map(message => message.content.map(block => block.text).join('\n')) + const saved = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, fb: ctx.flybot, home, warns, tool, refusalOf, callAsDsh, ask, received, saved, stop, agentOf } +} + +const runBot = async (fb, tools) => fb.scope('flybot-workflow').bot.create({ name: 'Runner ab', role: 'Run', ephemeral: true, tools }) + +test('the issue repro is refused: a tools:[] run Bot delivers nothing and asks nothing', async (t) => { + const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() + t.after(stop) + const id = await runBot(fb, []) + assert.deepEqual(saved().bots[id].stepTools, undefined, 'tools: [] is the case read as "nothing granted"') + + const before = readFileSync(join(home, 'state.json'), 'utf8') + await assert.rejects(() => callAsDsh('message_bot', { to: 'Writer', message: 'hi' }, id), new RegExp(EPHEMERAL_REFUSAL)) + await assert.rejects(() => callAsDsh('message_bot', { to: 'Writer', message: 'hi', reply_to: 'user' }, id), new RegExp(EPHEMERAL_REFUSAL)) + assert.deepEqual(received('b'), [], 'nothing was delivered to Writer') + assert.equal(await ask('message_bot', id), 'rejected', 'the approval seam agrees with the call guard') + assert.equal(await ask('message_bot', id, 'why not'), 'rejected') + // The harness's own question and secret tools are the same answer, so no card or request appears. + assert.match(await refusalOf('ask_user', id), /not a team member/) + assert.match(await refusalOf('request_secret', id), /not a team member/) + const state = saved() + assert.deepEqual(state.questions, {}, 'no question card was opened') + assert.equal(state.secretRequests, undefined, 'no secret request was recorded') + assert.equal(readFileSync(join(home, 'state.json'), 'utf8'), before, 'the whole attempt wrote nothing') +}) + +test('the constant refuses every name at the guard and the approval seam, with no side effect', async (t) => { + const { fb, home, callAsDsh, ask, refusalOf, received, saved, stop } = await start() + t.after(stop) + const id = await runBot(fb, []) + assert.equal(EPHEMERAL_DENIED_TOOLS.length, 29) + assert.equal(new Set(EPHEMERAL_DENIED_TOOLS).size, 29, 'no duplicate name') + assert.equal(new Set(EPHEMERAL_ALLOWED_TOOLS).size, EPHEMERAL_ALLOWED_TOOLS.length) + assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => ALLOWED.has(name)), [], 'a name is in one list only') + + const before = readFileSync(join(home, 'state.json'), 'utf8') + const args = { + message_bot: { to: 'Writer', message: 'hi' }, + stop_bot: { bot: 'Writer' }, + create_bot: { name: 'Made ab' }, + update_bot: { bot: 'Writer', role: 'New' }, + create_group: { name: 'Desk', members: ['Chief'] }, + update_group: { group: 'Desk', notice: 'x' }, + delete_group: { group: 'Desk' }, + post_to_group: { group: 'Desk', message: 'hi' }, + ask_user: { question: 'Go?', options: [{ label: 'Yes' }] }, + request_secret: { name: 'TOKEN', purpose: 'x' }, + message_friend_bot: { to: '@bob.home', message: 'hi' }, + get_friend_file: { id: 'att_x' }, + define_workflow: { name: 'w', nodes: [], edges: [] }, + run_workflow: { workflow: 'w', brief: 'b' }, + schedule_create: { prompt: 'x', title: 't' }, + } + for (const name of EPHEMERAL_DENIED_TOOLS) { + await assert.rejects(() => callAsDsh(name, args[name] ?? {}, id), new RegExp(EPHEMERAL_REFUSAL), name) + assert.equal(await ask(name, id), 'rejected', name) + } + assert.deepEqual(received('b'), [], 'no message ran') + const state = saved() + assert.deepEqual(state.questions, {}, 'no ask_user card') + assert.equal(state.secretRequests, undefined, 'no request_secret entry') + assert.deepEqual(Object.keys(state.rooms), [], 'no group was written') + assert.equal(readFileSync(join(home, 'state.json'), 'utf8'), before, 'the block wrote nothing') + + // Ordinary Bots keep the same tools, and a friend's Bot is unchanged too. + for (const name of ['message_bot', 'create_group', 'request_secret', 'recall']) assert.equal(refusalOf(name, 'a'), undefined, name) +}) + +test('a subagent child of a run Bot answers rejected; an ordinary Bot\'s child still falls through', async (t) => { + const { fb, ctx, ask, stop } = await start() + t.after(stop) + const id = await runBot(fb, []) + const child = { id: 'child-run', session: { id: 'child-run', header: { parentSession: id } } } + assert.equal(await ask('message_bot', child), 'rejected', 'the parent chain reaches the run Bot') + assert.equal(await ask('create_group', child), 'rejected') + assert.equal(await ask('read', child), 'allowed-once', 'a tool the run Bot keeps is still fine') + // An ordinary Bot's child is not adopted: its request still goes to DSH's own answerer. + const ordinary = { id: 'child-writer', session: { id: 'child-writer', header: { parentSession: 'b' } } } + const seen = [] + const verdict = ctx.listeners.get('approval/request')[0]({ agent: ordinary, toolName: 'message_bot' }, () => { seen.push('next'); return 'from-dsh' }) + assert.equal(await verdict, 'from-dsh') + assert.deepEqual(seen, ['next']) +}) + +test('the constant is a subset of the served names; served minus it is the documented allowlist', async (t) => { + const { ctx, stop } = await start({ browser: true }) + t.after(stop) + // The core half is measured, not listed: with the Desktop browser flag the registered tools are + // exactly TEAM_TOOLS (agents.test.js holds the same equality), so a core tool added with no + // classification shows up in `SERVED` and fails the equality below. + assert.deepEqual(sorted(ctx.registeredTools.keys()), sorted(TEAM_TOOLS)) + // The plugin half is read from the source, so a rename there fails here instead of failing open. + const social = registeredNames('../../plugins/social/src/social.js') + const workflow = registeredNames('../../plugins/workflow/index.js') + const tree = registeredNames('../../plugins/tree/src/tools.js') + assert.deepEqual(sorted(social), ['get_friend_file', 'list_friend_bots', 'message_friend_bot']) + assert.deepEqual(sorted(workflow), ['define_workflow', 'run_workflow']) + assert.deepEqual(sorted(tree), ['create_child_bot', 'create_team', 'find_bots', 'list_children', 'move_bot']) + // The tree's four other tools are behind `visibleTo: inTree` and reach no run Bot; create_child_bot + // is in the constant anyway so its refusal reads like the rest. + const SERVED = new Set([...ctx.registeredTools.keys(), ...HARNESS_TOOLS, ...social, ...workflow, 'create_child_bot']) + assert.deepEqual(EPHEMERAL_DENIED_TOOLS.filter(name => !SERVED.has(name)), [], 'every refused name is served') + assert.deepEqual(sorted([...SERVED].filter(name => !DENIED.has(name))), sorted(EPHEMERAL_ALLOWED_TOOLS), 'served minus refused is the allowlist') + for (const gated of tree.filter(name => name !== 'create_child_bot')) assert.equal(DENIED.has(gated) || ALLOWED.has(gated), false, gated) +}) + +test('the run Bot\'s own tool list loses the refused names while a team Bot keeps message_bot', async (t) => { + const { ctx, fb, stop } = await start({ browser: true }) + t.after(stop) + const id = await runBot(fb, []) + // What the profile serves globally, and what one Agent serves itself, as the real registry answers. + const served = new Set([...ctx.registeredTools.keys(), ...HARNESS_TOOLS, ...registeredNames('../../plugins/social/src/social.js'), ...registeredNames('../../plugins/workflow/index.js'), 'create_child_bot']) + const own = [] + const masks = new Map() + const hiddenOf = sessionId => new Set((masks.get(sessionId) ?? []).flatMap(mask => [...mask])) + ctx.tools.schemas = (agent) => { + const base = agent === undefined ? [...served] : [...served, ...own] + const hidden = agent === undefined ? new Set() : hiddenOf(agent.session?.id ?? agent.id) + return base.filter(name => !hidden.has(name)).map(name => ({ name })) + } + const restrict = sessionId => ({ deny }) => { + const unknown = deny.filter(name => !served.has(name)) + if (unknown.length > 0) throw new Error(`tools.restrict() names unknown tools ${unknown.join(', ')}`) + const mask = new Set(deny) + masks.set(sessionId, [...(masks.get(sessionId) ?? []), mask]) + return () => masks.set(sessionId, (masks.get(sessionId) ?? []).filter(entry => entry !== mask)) + } + const runAgent = { id, session: { id }, ctx: { tools: { restrict: restrict(id) }, effect: execute => execute() } } + const teamAgent = { id: 'b', session: { id: 'b' }, ctx: { tools: { restrict: restrict('b') }, effect: execute => execute() } } + const until = async (ok) => { for (let i = 0; i < 400 && !ok(); i += 1) await delay(5); assert.ok(ok(), 'the mask landed') } + ctx.emitArgs('agent/created', { agent: runAgent }) + await until(() => ctx.tools.schemas(runAgent).every(schema => !DENIED.has(schema.name)) && ctx.tools.schemas(runAgent).length > 0) + ctx.emitArgs('agent/created', { agent: teamAgent }) + await delay(30) + const runNames = new Set(ctx.tools.schemas(runAgent).map(schema => schema.name)) + assert.deepEqual([...runNames].filter(name => DENIED.has(name)), [], 'the run Bot\'s list carries no refused name') + assert.equal(runNames.has('message_bot'), false) + assert.deepEqual(sorted([...runNames]), sorted(EPHEMERAL_ALLOWED_TOOLS), 'and keeps exactly the allowlist') + assert.equal(ctx.tools.schemas(teamAgent).some(schema => schema.name === 'message_bot'), true, 'a team Bot keeps message_bot') +}) + +test('a refused name the mask cannot enforce stays listed with a warning; the guard still refuses it', async (t) => { + const { ctx, fb, warns, refusalOf, stop } = await start({ browser: true }) + t.after(stop) + const id = await runBot(fb, []) + // `list_secrets` is served to the Agent but not in the profile's global list, the one shape + // restrict() cannot name (#1271). It stays listed; the warn line says so; the guard is the refusal. + const served = new Set([...ctx.registeredTools.keys()].filter(name => name !== 'list_secrets')) + const own = ['list_secrets'] + const masks = new Map() + const hiddenOf = sessionId => new Set((masks.get(sessionId) ?? []).flatMap(mask => [...mask])) + ctx.tools.schemas = (agent) => { + const base = agent === undefined ? [...served] : [...served, ...own] + const hidden = agent === undefined ? new Set() : hiddenOf(agent.session?.id ?? agent.id) + return base.filter(name => !hidden.has(name)).map(name => ({ name })) + } + const runAgent = { + id, session: { id }, + ctx: { tools: { restrict: ({ deny }) => { const mask = new Set(deny); masks.set(id, [...(masks.get(id) ?? []), mask]); return () => masks.set(id, (masks.get(id) ?? []).filter(entry => entry !== mask)) } }, effect: execute => execute() }, + } + ctx.emitArgs('agent/created', { agent: runAgent }) + for (let i = 0; i < 400 && !warns.some(line => line.includes('list_secrets')); i += 1) await delay(5) + assert.ok(warns.some(line => line.includes('list_secrets') && line.includes('stay listed')), 'the warn line names the name it could not remove') + assert.equal(ctx.tools.schemas(runAgent).some(schema => schema.name === 'list_secrets'), true, 'it stays in the list') + assert.match(await refusalOf('list_secrets', id), /not a team member/, 'the guard refuses it anyway') +})