Skip to content

fix(slack): delivery correctness — threading, MCP blocklist, context injection#527

Open
leighstillard wants to merge 4 commits intochenhg5:mainfrom
leighstillard:fix/slack-bridge-delivery
Open

fix(slack): delivery correctness — threading, MCP blocklist, context injection#527
leighstillard wants to merge 4 commits intochenhg5:mainfrom
leighstillard:fix/slack-bridge-delivery

Conversation

@leighstillard
Copy link
Copy Markdown
Contributor

Summary

Four interlocking fixes to how cc-connect delivers messages on the Slack platform. Individually they look like small edges; together they close the failure mode where a cc-connect session would (a) post as the human user's claude.ai account instead of the bot, (b) drop intermediate events into the main channel instead of a thread, and (c) have the in-progress PlatformContext feature blocked behind a broken CI.

Commits

  1. feat(slack): inject channel/thread context block into agent prompt
    Adds a structured `## Slack Message Context` block (channel_id, channel_name, user_id, user_name, message_ts, thread_ts) to `core.Message.PlatformContext`. Threaded through `buildSenderPrompt` and populated by the Slack platform at all three message sites (real messages, app_mentions, slash commands). Lets the agent address the right channel/thread when making Slack-side decisions.

  2. fix(test): update buildSenderPrompt call sites for PlatformContext param
    Unblocks CI: the PlatformContext commit added a fifth parameter to `buildSenderPrompt` and updated every production call site, but missed the four test call sites in `core/engine_test.go`. Pass an empty `platformContext` in each test — the existing assertions about the sender header remain valid because the new prefix path is a no-op when platformContext is empty.

  3. feat(slack): thread Send replies off the triggering message ts
    `Platform.Send` previously posted un-threaded messages while `Reply` already threaded correctly via `slack.MsgOptionTS`. The asymmetry was invisible on most platforms but exposed on Slack because the core message path (`core/engine.go`, `core/streaming.go`, `core/progress_compact.go`, `core/relay.go`) overwhelmingly calls `Send` rather than `Reply` for progress and tool-use events. The result: the final agent reply would thread, but every intermediate progress update, tool-call announcement, and streamed partial would land un-threaded in the main channel. Lift the threading logic from `Reply` into `Send`: when `rc.timestamp` is present, append `slack.MsgOptionTS` before `PostMessageContext`.

  4. feat(claudecode): disable claude.ai cloud-managed MCP connectors by default
    The claude.ai Slack, Gmail, and Google Calendar MCP connectors are cloud-managed and tied to the authenticated claude.ai account's OAuth token. When the agent calls one of their tools, the API call happens AS the account owner — so Slack messages post with "Sent using @claude" attributed to the human account, not the cc-connect bot, and cc-connect's own delivery path (thread_ts, progress streaming, tool suppression) is bypassed entirely. Disallow them unconditionally in the claudecode agent's spawn args via `--disallowedTools`. Per-project config still applies and can extend this baseline but cannot override it.

Test plan

  • `go build ./...`
  • `go test ./core/... ./platform/slack/... ./agent/claudecode/...` (all pass after the test fix)
  • Manual: Slack session sending a new message — reply lands in-thread, MCP tool calls are unavailable, `cc-connect` log shows `--disallowedTools mcp__claude_ai_Slack,mcp__claude_ai_Gmail,mcp__claude_ai_Google_Calendar` in the spawn args

Notes

Each commit is independently revertible. The order matters: the test fix must land no later than the PlatformContext commit to keep CI green on any intermediate state.

🤖 Generated with Claude Code

leighstillard and others added 4 commits April 9, 2026 13:42
Adds a structured "## Slack Message Context" block that the Slack
platform populates with channel_id, channel_name, user_id, user_name,
message_ts, and (when present) thread_ts. The block is threaded through
core.Message.PlatformContext, stored on the engine's queued pending
messages, and prepended to the agent prompt by buildSenderPrompt.

Motivation: without this, the agent does not know which Slack
channel/thread the current conversation lives in, so MCP tool calls
back into Slack (reply, react, post to canvas, etc.) cannot be
routed correctly and thread-reply semantics fall back to
channel-level posts. Providing the context as a structured block up
front lets the agent address the right channel and preserve threading
without having to infer it.

Wiring:

- core/message.go: new PlatformContext field on core.Message.
- core/engine.go:
  - interactiveState.pendingMessages entries gain a platformContext
    field and the enqueue/dequeue sites copy it through.
  - buildSenderPrompt now takes (content, userID, platform,
    sessionKey, platformContext). The platform-context block is
    prepended unconditionally when present, then the existing
    `[cc-connect sender_id=... platform=... chat_id=...]` header is
    appended on top of that when injectSender is enabled. If neither
    applies, the original content is returned unchanged.
  - All three buildSenderPrompt call sites (fresh message, queued
    replay after session crash, continue-after-shutdown) now pass
    the platformContext through.
- platform/slack/slack.go:
  - New slackMessageContext() helper builds the structured block.
  - Populated at the three Slack Message paths where we construct a
    core.Message: real message events, app_mention events, and slash
    commands.
  - Channel/user name resolution is hoisted out of the struct literal
    into named locals so both the ChatName and the PlatformContext
    block can reuse the same resolved values without double lookups.

The feature is additive. No existing call paths change behaviour when
PlatformContext is empty — non-Slack platforms keep working unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2a99696 feat(slack): inject channel/thread context block added a fifth
parameter (platformContext string) to buildSenderPrompt and updated
every production call site, but left the four test call sites in
core/engine_test.go unchanged:

  core/engine_test.go:6402  TestBuildSenderPrompt_Enabled
  core/engine_test.go:6413  TestBuildSenderPrompt_Disabled
  core/engine_test.go:6423  TestBuildSenderPrompt_EmptyUserID
  core/engine_test.go:6462  TestBuildSenderPrompt_DifferentPlatforms

The feat/run-as-user branch therefore had a broken test suite for a
short period — `go build` still succeeded because the three production
call sites in core/engine.go were all updated, so the live binary was
fine, but `go test ./core/...` failed with "not enough arguments".

Pass an empty platformContext "" at each test call site. The existing
test assertions about the `[cc-connect sender_id=... chat_id=...]`
header remain valid because buildSenderPrompt's new prefix behaviour
is a no-op when platformContext is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Platform.Send previously posted un-threaded messages, while Reply
already threaded correctly via slack.MsgOptionTS. The asymmetry was
invisible in most platforms but exposed on Slack specifically because
the core message path (core/engine.go, core/streaming.go,
core/progress_compact.go, core/relay.go) overwhelmingly calls Send
rather than Reply for progress, tool-use events, and queued message
replay. The net effect was that the agent's final reply would thread
(via Reply), but every intermediate progress update, tool-call
announcement, and streamed partial would land un-threaded in the main
channel — the "giant mess" failure mode where each new thought_balloon
and wrench spam the channel as standalone messages.

Lift the threading logic from Reply into Send: when rc.timestamp is
present, append slack.MsgOptionTS before PostMessageContext. For
genuinely standalone posts that don't have a triggering ts
(slash commands, ReconstructReplyCtx reply contexts), timestamp is
empty and Send falls back to its previous non-threaded shape.

In cc-connect's normal Slack flow every triggering user message has
its ts captured into replyContext.timestamp at the incoming-event
site, so this effectively threads the entire conversation — final
reply, progress, tool noise, errors, notifications — under the
user's original message. One thread per conversation. Main channel
stays quiet. Parallel conversations in the same channel naturally
fork into separate threads without any per-session book-keeping
beyond the session key that already carries the channel + user,
which also sets up multi-thread sessioning as a later improvement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…efault

The claude.ai Slack, Gmail, and Google Calendar MCP connectors are
cloud-managed and tied to the authenticated claude.ai account's OAuth
token rather than to the cc-connect bot identity. When the agent calls
one of their tools, the resulting API call happens AS the account
owner — so (for Slack) messages post with "Sent using @claude"
attributed to the human account instead of the cc-connect bot, and
cc-connect's own delivery path (thread_ts, progress streaming, tool
suppression, etc.) is bypassed entirely.

This is especially bad in sandboxed deployments where several Unix
users share one claude.ai account via file-copied credentials: every
sandbox user inherits access to every connector authorised on the
shared account, even though the human only intended to authorise the
connector for their own claude.ai use.

cc-connect owns message delivery on every platform it bridges to, and
those connectors are a foot-gun nobody should be firing from inside a
cc-connect session. Disallow them unconditionally in the claudecode
agent's spawn args. Per-project disallowed_tools config still applies
and can extend this list, but cannot override the baseline.

Observed failure mode that motivated the fix: a recent credentials
refresh gave the sandbox user a fresh OAuth token that (unlike the
stale one it replaced) had the claude.ai Slack connector authorised.
The next Slack session started routing replies through
mcp__claude_ai_Slack__slack_send_message, posting them as the human
user with "Sent using @claude", in the main channel, outside any
thread, with every ":thought_balloon:" and ":wrench:" tool call
landing as separate messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Owner

@chenhg5 chenhg5 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Four distinct correctness fixes — the MCP blocklist (Slack/Gmail/Calendar) is the most security-sensitive, and the threading fix via MsgOptionTS addresses a real delivery bug. buildSenderPrompt signature change is backwards-compatible (new param is added, all call sites updated). All CI pass.

PR: #527

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants