# Two Grok Bots Leaked a CEO's Bank Balance to Slack

> Two 'separate' Grok Bots leaked a CEO's bank balance to public Slack. I reproduced it, then built an agentgateway guardrail that blocks the exact handoff.

- Canonical URL: https://webofmike.com/grok-bot-cross-agent-leak/
- Author: Mike Moore (https://webofmike.com/about/)
- Published: 2026-10-24
- Last modified: 2026-10-24
- Tags: MCP, AI Gateways, Security, AI Agents
- Cite as: Mike Moore, "Two Grok Bots Leaked a CEO's Bank Balance to Slack", Web of Mike (webofmike.com), 2026-10-24. https://webofmike.com/grok-bot-cross-agent-leak/


Two "separate" Grok Bots leaked a CEO's bank balance into a public Slack channel on October 19. Nobody jailbroke anything. Nobody injected a malicious prompt. One bot did its job, a second bot did its job, and the gap between those two jobs is where the balance leaked through.

I rebuilt the failure with two toy MCP servers, confirmed it reproduces the leak with no tricks involved, then put [agentgateway](https://agentgateway.dev)'s MCP guardrails in front of the dangerous half and watched the identical call get rejected. Code: [github.com/themsquared/grok-bot-cross-agent-leak](https://github.com/themsquared/grok-bot-cross-agent-leak).

## What happened on October 19

[Techdirt](https://www.techdirt.com/2026/10/19/whoops-grok-bot-spills-tech-ceos-bank-data-in-public-slack/) reported that XMTP Labs CEO Shane Mac had connected a Grok Bot to his personal bank account. By his own account it was read-only and had no access to Slack. A second Grok Bot running on the same machine did have Slack access. The two bots "collaborated," and his bank balance ended up posted to his company's public Slack channel, with the post appearing to come from him. His own screenshot of it is dated October 12.

[The HN thread](https://news.ycombinator.com/item?id=50169169) spends its 14 comments circling the same question: what does "separate" mean here? Did the bots share memory, share a process, pass messages through some shared context? Nobody outside xAI knows the mechanism. But the shape of the failure doesn't depend on the mechanism. Somewhere, data that one tool produced, carrying real financial sensitivity, reached a different tool whose entire job is "post whatever text you're given to a public channel." Nothing in between asked whether that handoff was allowed.

## Reproducing it without Grok or Slack

The demo uses two Node.js MCP servers that stand in for the two bots:

```js
// finance-server.js: one tool, read-only
server.registerTool("get_balance", { /* ... */ }, async ({ account_id }) => {
  const acct = ACCOUNTS[account_id];
  return {
    content: [{
      type: "text",
      text: `Balance for ${account_id} (${acct.owner}): ${acct.balance} [FINANCIAL-RESTRICTED]`,
    }],
  };
});
```

```js
// slack-server.js: one tool, posts whatever text it gets
server.registerTool("post_message", { /* ... */ }, async ({ channel, text }) => {
  appendFileSync(LOG_PATH, `[${channel}] ${text}\n`);
  return { content: [{ type: "text", text: `Posted to ${channel}: ${text}` }] };
});
```

Calling `get_balance` and handing its output straight to `post_message`, with no gateway and no check, reproduces the leak exactly:

```
$ npx @modelcontextprotocol/inspector --cli node mcp-server/slack-server.js \
    --method tools/call --tool-name post_message \
    --tool-arg channel="#general" \
    --tool-arg "text=Balance for acct-shane (Shane M.): $482,019.44 [FINANCIAL-RESTRICTED]"

$ cat public-channel.log
[#general] Balance for acct-shane (Shane M.): $482,019.44 [FINANCIAL-RESTRICTED]
```

That is the whole incident, minus the branding. A read-only tool produced sensitive output. A posting tool accepted it as ordinary text. Nothing in the path between them had an opinion about that.

## Putting a gateway between the two tools

The fix doesn't live inside either bot. A bot built to post Slack messages will keep posting whatever text it's handed; that's correct behavior for the tool it is. The fix has to sit at the boundary between the two tools, which is exactly where an MCP gateway sits.

I put the Slack server, and only the Slack server, behind agentgateway with one guardrail policy:

```yaml
mcp:
  policies:
    mcpGuardrails:
      processors:
      - kind: expression
        methods: {"tools/call": request}
        condition: >-
          mcp.params.name == 'post_message' &&
          has(mcp.params.arguments) &&
          has(mcp.params.arguments.text) &&
          mcp.params.arguments.text.contains('[FINANCIAL-RESTRICTED]')
        reject: "blocked: this message carries a FINANCIAL-RESTRICTED tag and this tool has no cross-agent-relay grant for that classification"
  targets:
  - name: slack-poster
    stdio:
      cmd: node
      args: ["mcp-server/slack-server.js"]
```

One CEL expression, evaluated on the `tools/call` request before it ever reaches the Slack server. The same call that leaked the balance a minute earlier now gets rejected before the tool runs, and `public-channel.log` stays empty:

```
$ npx @modelcontextprotocol/inspector --cli http://localhost:13000/mcp \
    --method tools/call --tool-name post_message \
    --tool-arg channel="#general" \
    --tool-arg "text=Balance for acct-shane (Shane M.): $482,019.44 [FINANCIAL-RESTRICTED]"
{
  "content": [{ "type": "text", "text": "blocked: this message carries a FINANCIAL-RESTRICTED tag and this tool has no cross-agent-relay grant for that classification" }],
  "isError": true
}
```

An ordinary, unclassified message through the same gateway still posts normally. The policy is scoped to the classification, not a blanket lock on the tool:

```
$ npx @modelcontextprotocol/inspector --cli http://localhost:13000/mcp \
    --method tools/call --tool-name post_message \
    --tool-arg channel="#general" --tool-arg "text=deploy finished, all green"

$ cat public-channel.log
[#general] deploy finished, all green
```

## The part worth generalizing

This demo's tag is a plain string, which is a simplification: a real deployment would carry structured provenance through the response and check a real field, not grep for a marker in free text. That detail is not the point. The point is where the check lives. It does not matter, for purposes of this fix, whether the two agents involved were "separate" processes, shared a context window, or were the same agent calling two tools in sequence. A policy sitting at the boundary between the tools sees the request either way, and the HN thread's open question about what "separate" meant stops mattering once nothing can cross that boundary without a grant.

Code: [github.com/themsquared/grok-bot-cross-agent-leak](https://github.com/themsquared/grok-bot-cross-agent-leak). The same guardrail mechanism, applied to a different failure mode (rejecting a destructive tool call before it runs, masking secrets in a tool's response), is in an earlier post, [MCP Tool-Call Guardrails: Gating Agents Before the Tool Runs](/tool-call-guardrails/).

