Repository navigation
fix(host): a Workflow run Bot keeps the team tools, whatever the node's tools list says #1445
Description
Written-by: FlyBot Lead
A workflow node declares the tools its run Bot may use, and the host only enforces that for connectors. Everything else reaches the run Bot unchanged, including the whole team tool set: an ephemeral Bot can message a team member (and with reply_to: "user" make that member speak to the owner on its behalf), stop Bots, create and edit Bots and groups, save capabilities, request secrets and arm schedules.
Measured on 0915151605a9a424f731e9d27f63ad705c571520 (main). The only commit after it, b20524fe, touched plugins/workflow/**, its changelog and docs/subsystems/workflow.md — none of the core files or line numbers quoted here. The owner's decision on 2026-10-27 was to tighten the behaviour, not the wording.
Reproduction
core/test/flybot.test.js (its local start() helper; precedents at :173-186 and :207-230):
const id = await fb.scope('flybot-workflow').bot.create({ name: 'Runner ab', ephemeral: true, tools: [] })
const out = await tool('message_bot', { to: 'Writer', message: 'hi' }, id)The third argument to tool() is the run Bot's session id, and bot.create returns exactly that (roster.js:34-37,100).
Observed: Delivered to Writer. Its reply will arrive later as a new message; do not wait for it. and the message is really delivered (received('b') is ["[Message from Runner ab]\nhi"]). With reply_to: "user" the answer is Delivered to Writer. It will answer the user in its own chat; nothing comes back to you, so point the user to Writer's chat instead of relaying.
Why nothing stops it: core/src/host/tools.js:42 resolves callerBot from the agent's own session, and the run Bot's chat session is in chatOwners (store.js:564 indexes every state.bots entry, ephemeral included), so the sender === undefined check at tools.js:203-204 does not fire; the reply_to: "user" branch has no second gate.
What the host actually enforces today
core/src/host/connector-access.js:54allowed()consultsstepGrantsonly forbot.ephemeral— i.e. only connectors.core/src/host/connector-access.js:101thectx.tools.guardentry returnsundefinedfor anything that is not a connector (:103) and not inSCHEDULE_TOOLS, so every other tool dispatches.core/src/host/approvals.js:18ephemeralToolVerdictanswersallowed-oncefor every non-MCP, non-escalation request (:29), so the approval seam does not narrow it either.- No
toolsvalue narrows the non-connector surface:permissionFor([])isworkspace-write(plugins/workflow/src/ephemeral.js:8-14, "A node with no tools is not left on a read-only default"), and[]behaves exactly like['read']there. A node that declares no tools is the case most likely to be read as "nothing was granted", and it is the case with the widest misreading.
Expected behaviour
For a Bot whose record is ephemeral, every tool whose effect reaches past that Bot's own session is refused, and the refusal is one sentence that names the reason — a running Bot is not part of the team — without naming a substitute route. Where the served list can be filtered, the same names are also hidden through the existing restrict({ deny }) seam; a name the profile serves but the mask cannot enforce stays in the list with a warning only, so the guard — not the list — is what refuses. and ephemeralToolVerdict answers rejected for the same names, so the approval seam and the call guard cannot disagree.
Nothing changes for ordinary Bots, remote (friend) Bots or quartered/group rooms.
Scope
Refuse (one exported constant in core/src/host/tools.js, referenced by the guard, so a tool added later without being listed shows up as a missing entry rather than as a silent pass):
core/src/host/tools.js:message_bot(:184),stop_bot(:256),create_bot(:283),update_bot(:321),create_group(:393),update_group(:422),delete_group(:493),post_to_group(:510),ask_user(:544),save_capability(:732),delete_capability(:762),attach_capability/detach_capability(:776registration loop),track_tasks(:839), plusgroup_relay.core/src/host/secrets.js:request_secret,list_secrets(the latter tells the Bot the value may be used as$DSH_SECRET_<NAME>, which is true given the shell gap below).- Harness-side write tools served to that Bot:
send_message,schedule_create,schedule_update,schedule_delete(enumerated fromctx.tools.schemas()at run time, not from a list in this issue). plugins/social/src/social.js:message_friend_bot(:2629),get_friend_file(:2656),list_friend_bots(:2619) — registered withoutvisibleTo, socore/src/host/hooks.js:757treats them as visible to every Bot.message_friend_bottakesfilesas arbitrary absolute paths, so it can send any readable file,~/.dsh/bot/secrets.jsonincluded.plugins/workflow/src:define_workflow(rewrites the saved graph — a run Bot could grant itself more tools for the next run) andrun_workflow(starts a run, optionally against a seatedbotId, which reaches a real Bot's team surface). These names are listed in the core constant;plugins/workflow/**is not edited by this issue.read_browser.recall—core/src/host/memory.jsreadScopesreturns[ownScope(botId), TEAM]whenscopeis omitted, so a run Bot reads the team memory and the answer lands in the run's own persisted output. Refused; the cost is that a run Bot cannot read even its own memory back inside a run, soremember/forget(below) are write-only for it.
Leave, with the reason for each (all are read-only, and each reads only what the run Bot itself may see):
list_bots— reads the team roster.hooks.jsalready leaves an ephemeral Bot out of its own roster answer; it writes nothing and never names a chat to send to.list_models— the provider/model catalogue. Static team configuration, no per-Bot state.list_capabilities— the capability catalogue. Read-only; attaching or detaching one is refused (attach_capability,detach_capability,save_capability,delete_capabilityabove).bot_schedules— the caller's own schedules, read from the Session index it already belongs to. It cannot name another Session.schedule_list— the same list through the harness's tool. Arming, changing or disarming one is refused (schedule_create/update/deleteabove).read_own_chat— its own conversation (the run's own record).read_group_chat— a group's history for a room the caller is in. A run Bot is in no room, so the answer is empty; it writes nothing.remember— the only memory it can write is its own scope.writeDenied(core/src/host/memory.js:520-524) refusesteamunlesswritesTeam(botId)(:492) holds — a Main Bot, or the admin of a room that is not face-to-face — and refuses another Bot's memory unlessrt.isMain(botId)(core/src/host/store.js:539); a run Bot is neither, and it cannot make itself one becausecreate_group/update_groupare refused above. What it can write lands under its own Bot id (a freshsession-<uuid>per create) and no later Bot reads that scope; the text still passeslooksSecretand the 300-character cap. This is also why the kept set does not grow to include a second write tool: every kept name is read-only, or writes only what no other Bot can read.forget— the same scope rule, deleting within the caller's own memory only.
find_bots and create_child_bot need no denial either: plugins/tree/src/tools.js:80/:109 gate them with visibleTo: inTree and a run Bot is not a tree member, so they are already unreachable. create_child_bot is listed in the constant anyway, so its answer is the same sentence as the rest; create_team/move_bot are behind the same tree gate.
FlyBot Security's audit and the Lead's own read settled the open question: remember/forget cannot reach team scope or another Bot's memory from a run Bot — memory.js:520-524 writeDenied gates team on writesTeam and another Bot's memory on rt.isMain, and a run Bot is neither — so they stay. The same audit found that recall can reach team (see the refused list), which is why it is refused and the constant is 29 rather than the audited 28.
write/edit/bash stay for the run's own workspace; the shell's reach is the separate question below.
Out of scope, deliberately: the shell. core/src/host/secrets.js:522-554 resolveEnv checks only bot.remote (:538) and canUse (:34-35), and never bot.ephemeral, so a run Bot's bash receives every secret whose scope is all; dsh-shell-env merges that into the overlay without branching on the sandbox mode, so curl/git can leave the machine on workspace-write alone. That is the separate question "may a run Bot use the owner's keys", and it is tracked on its own. This issue closes the team-tool door only; it must not be described as isolating a run Bot from the team.
Docs
docs/subsystems/workflow.md:52andplugins/workflow/README.md:23("toolson the node is what that Bot may use …") become false once this lands. Both belong to the workflow module; this issue quotes them and does not edit them.docs/subsystems/core-host.md(the run Bot / connector section) is updated with the implementation.
Found by the audit
Fixed here, in the same change:
recallis refused (above).core/src/host/approvals.jskeeps its two existing lookups first (groupOwners, thenchatOwners); only when both find nothing does it askrt.whoseAgent(request.agent)and adopt the answer when that resolves to anephemeralBot, so asubagentchild session started by a run Bot answersrejectedfor these names instead of falling throughnext(). An ordinary Bot'ssubagentchild is untouched: it still falls through tonext()exactly as before, because the parent is not ephemeral. The same helpercore/src/host/secrets.js:514uses, so the two seams agree.
Not fixed here, recorded so they are not read as done:
- A deleted record leaves a window.
deleteBotremovesstate.botsand thechatOwnersindex while the Session may still be running for one more call;selfOfthen finds no Bot andwhoseAgentanswers{ plain: true }, which the guard and the connector side both treat as the owner's own Session. In steady state the turn is stopped (archiveSession(…, { stopActivity: true })) before that, so the window is the one where a delete races the last tool call. The fix is a tombstone:deleteBotrecords the Session inpastOwners(as the segment index already does), so it resolves asunknown— fail-closed on the connector path already — and the team-tool refusal treats "no such Bot" as refused too. That is its own change, not this one. externalprocess Bots run their tools in another process and do not pass these guards at all. Today only group participants use one and a Workflow node does not, but the denial must not be described as global.list_botsandlist_capabilitiesstill answer with the team's Bot names, their roles and a room's absoluteworkDir. Read-only; accepted here, listed so the next round can decide.- The refusal sentence,
A running Bot is not a team member, is exact in steady state; in the deleted-record window above the Bot is not in the roster at all, so read the sentence as describing the run rather than the record. - A node that names
githubin itstoolsstill reachesmcp__github__*throughstepGrants— commenting and merging included. That is the designed connector grant, not a hole this issue closes; do not describe a run Bot as unable to leave its node. - The memory panel endpoints (
core/src/host/memory.js:1058-1116,memory-ask/memory-forget) write any scope as the user, bybody.botId, and a running Bot can only reach them through the shell. Out of scope here by the Chief's ruling; recorded so it is not read as closed.
Acceptance
- Table-driven test over the constant: for every name, the call is refused and the side effect did not happen — the assertions are "nothing delivered", "no
questions/secretRequestsentry", andstate.jsonbyte-identical before and after the block of calls, which covers a created Bot, group, capability, schedule or board write in one measurement. - The constant is a subset of the tool names the run Bot is actually served — a rename or a removed tool fails the test instead of silently reopening the door.
- The served names minus the constant equal the documented allowlist (one exported list, same test), so a tool added by any module lands in neither list and fails the test. The denied names span modules (
plugins/social,plugins/workflow, the harness), so a rename there would otherwise fail open: the test is what forces that rename back into the core constant. - The example above is red before the change and green after it.
Where the enforcement sits, exactly: the guard is the refusal. Every one of the 29 names is refused in ctx.tools.guard before dispatch, and the approval seam (ephemeralToolVerdict) answers rejected for the same names, so the two paths cannot disagree. The names are also removed from the run Bot's own tool list through the existing restrict({ deny }) seam, but that evidence is fixture-level: in the served-tools fixture the run Bot's ctx.tools.schemas(agent) carries none of them, and a team Bot keeps message_bot. The mask's enforceable pass filters by the global ctx.tools.schemas() and only warns about what it drops, so a real profile's list is not claimed to be clean — the guard is. The real profile's schemas() was not printed — the unit fixture's implementation is a stub — so this issue does not claim a measured production tool table.
tests-ok and verify green at the tip, with the counts measured on the PR's base.
Activity
Birfy commentedon Oct 27, 2026
Written-by: Triage
agent-ready at main
53d2c025b12590b8f78782e46960be0a8f809692(read 2026-10-27T15:02Z). Nohold, no assignee, and no open pull request names this issue (body search for1445over the 13 open PRs: none; the closest workflow ones, #1431/#1427/#1416, touch different files and different issues).The three conditions hold:
$DSH_SECRET_*stays out of scope by the same decision.plugins/workflow/**anddocs/subsystems/workflow.mdare quoted, not edited.core/src/host/tools.js, referenced byctx.tools.guard, mirrored inephemeralToolVerdict, the same names also removed through the existingrestrict({ deny })seam, anddocs/subsystems/core-host.mdupdated. The keep list is closed by "read-only, and reads only what the run Bot itself may see", withremember/forgetjustified bywriteDenied(team scope and another Bot's memory both refused).hold, not tracking/umbrella, not team-via-groups work. One defect, one change set, its own acceptance.Re-read at the tip, so this rests on the code and not only on the body:
core/src/host/connector-access.js:101-103— thectx.tools.guardentry returnsundefinedfor a name that is neither a connector nor inSCHEDULE_TOOLS: every other tool dispatches.core/src/host/approvals.js:24-30—ephemeralToolVerdictanswersallowed-oncefor every non-MCP, non-escalation request, so the approval seam does not narrow it either.core/src/host/tools.js:203-204—message_botrefuses only whencallerBot()isundefined; a run Bot's Session is inchatOwners, and thereply_to: "user"branch has no second gate.plugins/workflow/src/ephemeral.js:10-13—permissionFor([])isworkspace-write, sotools: []behaves exactly like['read']: the case most likely read as "nothing granted" is the widest one.Risk is T3, computed from the diff, not a label (
.github/agent/risk-rules.json):core/src/host/approvals.jsis a T3 path;tools.jsalone is T2 and the docs T0. So the pull request needs a PASS at the tip that lists itsRan:commands, greentests-okat that same tip, then the queue. Dev claims per AGENTS.md (assignBirfy,agent-working, one claim line) and opens the PR withFixes #1445,Owner:/Reviewer:in the first 20 lines andagent-pr.What the implementer must land:
core/test/(bot.create({ name, ephemeral: true, tools: [] }), thentool('message_bot', { to: <team Bot>, message: 'hi' }, id); precedentscore/test/flybot.test.js:173-186, 207-230): red at the base tip, green after. Assert the one-sentence refusal names the reason ("a running Bot is not part of the team"), that nothing was delivered, and thatreply_to: "user"is refused the same way.ctx.tools.guardbefore dispatch andephemeralToolVerdictanswersrejected, with the side effect absent — nothing delivered, noquestions/secretRequestsentry, andstate.jsonbyte-identical before and after the block (one comparison covers a created Bot, group, capability, schedule and board write).ctx.tools.schemas(agent)carries none of the denied names and a team Bot keepsmessage_bot; a name the profile serves but the mask cannot enforce stays listed with a warning — the guard is the refusal, and the test does not claim a real profile's list is clean.toolsis what that Bot may use" belong toplugins/workflow/**, which this issue quotes but does not edit.Birfy commentedon Oct 27, 2026
Claimed by Dev — branch
dev/1445-run-bot-team-tools, first step: add the exported 29-name deny constant incore/src/host/tools.jsand make the red repro fail.fix(host): refuse the team tools to a Workflow run Bot
fix(host): refuse the team tools to a Workflow run Bot
fix(workflow): show a failed sibling run on the stage with its reason (…