Skip to content

fix(host): a Workflow run Bot keeps the team tools, whatever the node's tools list says #1445

Closed

Description

@HugoXK

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:54 allowed() consults stepGrants only for bot.ephemeral — i.e. only connectors.
  • core/src/host/connector-access.js:101 the ctx.tools.guard entry returns undefined for anything that is not a connector (:103) and not in SCHEDULE_TOOLS, so every other tool dispatches.
  • core/src/host/approvals.js:18 ephemeralToolVerdict answers allowed-once for every non-MCP, non-escalation request (:29), so the approval seam does not narrow it either.
  • No tools value narrows the non-connector surface: permissionFor([]) is workspace-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 (:776 registration loop), track_tasks (:839), plus group_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 from ctx.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 without visibleTo, so core/src/host/hooks.js:757 treats them as visible to every Bot. message_friend_bot takes files as arbitrary absolute paths, so it can send any readable file, ~/.dsh/bot/secrets.json included.
  • plugins/workflow/src: define_workflow (rewrites the saved graph — a run Bot could grant itself more tools for the next run) and run_workflow (starts a run, optionally against a seated botId, 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.js readScopes returns [ownScope(botId), TEAM] when scope is 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, so remember/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.js already 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_capability above).
  • 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/delete above).
  • 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) refuses team unless writesTeam(botId) (:492) holds — a Main Bot, or the admin of a room that is not face-to-face — and refuses another Bot's memory unless rt.isMain(botId) (core/src/host/store.js:539); a run Bot is neither, and it cannot make itself one because create_group/update_group are refused above. What it can write lands under its own Bot id (a fresh session-<uuid> per create) and no later Bot reads that scope; the text still passes looksSecret and 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:52 and plugins/workflow/README.md:23 ("tools on 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:

  • recall is refused (above).
  • core/src/host/approvals.js keeps its two existing lookups first (groupOwners, then chatOwners); only when both find nothing does it ask rt.whoseAgent(request.agent) and adopt the answer when that resolves to an ephemeral Bot, so a subagent child session started by a run Bot answers rejected for these names instead of falling through next(). An ordinary Bot's subagent child is untouched: it still falls through to next() exactly as before, because the parent is not ephemeral. The same helper core/src/host/secrets.js:514 uses, so the two seams agree.

Not fixed here, recorded so they are not read as done:

  • A deleted record leaves a window. deleteBot removes state.bots and the chatOwners index while the Session may still be running for one more call; selfOf then finds no Bot and whoseAgent answers { 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: deleteBot records the Session in pastOwners (as the segment index already does), so it resolves as unknown — 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.
  • external process 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_bots and list_capabilities still answer with the team's Bot names, their roles and a room's absolute workDir. 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 github in its tools still reaches mcp__github__* through stepGrants — 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, by body.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/secretRequests entry", and state.json byte-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

  1. Birfy commented on Oct 27, 2026

    @Birfy
    Collaborator

    Written-by: Triage

    agent-ready at main 53d2c025b12590b8f78782e46960be0a8f809692 (read 2026-10-27T15:02Z). No hold, no assignee, and no open pull request names this issue (body search for 1445 over the 13 open PRs: none; the closest workflow ones, #1431/#1427/#1416, touch different files and different issues).

    The three conditions hold:

    • Design conflict resolved. The body records the owner's 2026-10-27 decision — tighten the behaviour, not the wording — and the refused/kept lists each carry a reason, so no question is left for the owner. The two neighbours are already split out: fix(host): a deleted Bot's Sessions still pass the team tools, so a call in flight runs with the owner's licence #1453 owns the deleted-record window (the tombstone), and the shell's reach over $DSH_SECRET_* stays out of scope by the same decision. plugins/workflow/** and docs/subsystems/workflow.md are quoted, not edited.
    • Behaviour and scope are concrete. One exported constant in core/src/host/tools.js, referenced by ctx.tools.guard, mirrored in ephemeralToolVerdict, the same names also removed through the existing restrict({ deny }) seam, and docs/subsystems/core-host.md updated. The keep list is closed by "read-only, and reads only what the run Bot itself may see", with remember/forget justified by writeDenied (team scope and another Bot's memory both refused).
    • Not 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 — the ctx.tools.guard entry returns undefined for a name that is neither a connector nor in SCHEDULE_TOOLS: every other tool dispatches.
    • core/src/host/approvals.js:24-30 — ephemeralToolVerdict answers allowed-once for every non-MCP, non-escalation request, so the approval seam does not narrow it either.
    • core/src/host/tools.js:203-204 — message_bot refuses only when callerBot() is undefined; a run Bot's Session is in chatOwners, and the reply_to: "user" branch has no second gate.
    • plugins/workflow/src/ephemeral.js:10-13 — permissionFor([]) is workspace-write, so tools: [] 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.js is a T3 path; tools.js alone is T2 and the docs T0. So the pull request needs a PASS at the tip that lists its Ran: commands, green tests-ok at that same tip, then the queue. Dev claims per AGENTS.md (assign Birfy, agent-working, one claim line) and opens the PR with Fixes #1445, Owner:/Reviewer: in the first 20 lines and agent-pr.

    What the implementer must land:

    1. Red first. The body's repro in core/test/ (bot.create({ name, ephemeral: true, tools: [] }), then tool('message_bot', { to: <team Bot>, message: 'hi' }, id); precedents core/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 that reply_to: "user" is refused the same way.
    2. Table over the exported constant. For every name: refused in ctx.tools.guard before dispatch and ephemeralToolVerdict answers rejected, with the side effect absent — nothing delivered, no questions/secretRequests entry, and state.json byte-identical before and after the block (one comparison covers a created Bot, group, capability, schedule and board write).
    3. The constant is a subset of the names the run Bot is actually served — a rename or a removed tool fails the test instead of silently reopening the door.
    4. Served names minus the constant equal the documented allowlist (one exported list, same test): a tool added by any module lands in neither list and fails.
    5. Fixture-level mask evidence. In the served-tools fixture the run Bot's ctx.tools.schemas(agent) carries none of the denied names and a team Bot keeps message_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.
    6. Do not widen scope. Do not fold in fix(host): a deleted Bot's Sessions still pass the team tools, so a call in flight runs with the owner's licence #1453 or the shell/secret question; the body's "Not fixed here" list is the scope line, and the docs that describe "tools is what that Bot may use" belong to plugins/workflow/**, which this issue quotes but does not edit.
  2. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 27, 2026
  3. self-assigned this
    on Oct 27, 2026
  4. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 27, 2026
  5. Birfy commented on Oct 27, 2026

    @Birfy
    Collaborator

    Claimed by Dev — branch dev/1445-run-bot-team-tools, first step: add the exported 29-name deny constant in core/src/host/tools.js and make the red repro fail.

  6. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 27, 2026
  7. added 2 commits that reference this issue on Oct 27, 2026
    be6692e
    ecd044e
  8. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions