Skip to content

Slack: add opt-in interactive reply directives#44607

Merged
vincentkoc merged 16 commits intomainfrom
vincentkoc-code/slack-block-kit-interactions
Mar 13, 2026
Merged

Slack: add opt-in interactive reply directives#44607
vincentkoc merged 16 commits intomainfrom
vincentkoc-code/slack-block-kit-interactions

Conversation

@vincentkoc
Copy link
Member

@vincentkoc vincentkoc commented Mar 13, 2026

Summary

  • Problem: agent replies can send raw Slack Block Kit after Slack: support Block Kit payloads in agent replies #44592, but there was still no simple agent-facing path for interactive reply controls.
  • Why it matters: issue [Feature]: Slack Block Kit support for agent messages #12602 asks for Slack quick actions and selection menus, but these need to be explicitly enabled like Telegram capabilities instead of turning on by default.
  • What changed: added [[slack_buttons: ...]] and [[slack_select: ...]] reply directives behind channels.slack.capabilities.interactiveReplies, disabled by default, with agent prompt hints that reflect the current config.
  • What did NOT change: this PR does not add arbitrary modal authoring or a general-purpose Slack Block Kit schema language for agents.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • Slack agents can emit [[slack_buttons: Label:value, Other:other]] when channels.slack.capabilities.interactiveReplies is enabled.
  • Slack agents can emit [[slack_select: Placeholder | Label:value, Other:other]] when channels.slack.capabilities.interactiveReplies is enabled.
  • Slack interactive reply directives are disabled by default.
  • Slack agent prompt hints now reflect whether interactive replies are enabled or disabled.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22 + pnpm workspace
  • Integration/channel: Slack reply normalization and prompt hints
  • Relevant config: channels.slack.capabilities.interactiveReplies=true

Steps

  1. Normalize a reply containing [[slack_buttons: ...]] or [[slack_select: ...]] with Slack interactive replies disabled.
  2. Verify the reply text stays unchanged and no Slack blocks are generated.
  3. Enable channels.slack.capabilities.interactiveReplies=true.
  4. Normalize the same reply again and verify it is converted into channelData.slack.blocks.

Expected

  • Slack directives stay literal text when the capability is disabled.
  • Slack blocks are generated only when the capability is enabled.
  • Existing Slack interaction handlers can receive the resulting actions.

Actual

  • Matches expected in targeted tests.

Evidence

  • Targeted tests updated and passed locally
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios: disabled-by-default behavior, enabled directive compilation, responsePrefix ordering, Slack prompt hints, capability-shape handling.
  • Edge cases checked: no-directive text, directive-only select messages, existing account/global capability lookups.
  • Not verified: live Slack workspace delivery and click-through against a real bot.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? Optional
  • Migration needed? No

Failure Recovery (if this breaks)

  • How to disable quickly: leave channels.slack.capabilities.interactiveReplies unset or set it to false.
  • Files/config to restore: Slack capability config and reply normalization changes in this PR.
  • Known bad symptoms reviewers should watch for: raw directives compiling when disabled, or enabled directives staying visible instead of becoming blocks.

Risks and Mitigations

  • Risk: config gating could drift from account/global Slack config resolution.
    • Mitigation: added capability-resolution coverage and prompt-hint tests for disabled and enabled states.
  • Risk: malformed directives could produce invalid Slack blocks when enabled.
    • Mitigation: parser ignores malformed choices and sender-side block validation still applies.

@vincentkoc vincentkoc self-assigned this Mar 13, 2026
@openclaw-barnacle openclaw-barnacle bot added channel: slack Channel integration: slack size: M maintainer Maintainer-authored PR labels Mar 13, 2026
@vincentkoc vincentkoc changed the title Slack: add interactive reply directives for agent messages Slack: add opt-in interactive reply directives Mar 13, 2026
@vincentkoc vincentkoc marked this pull request as ready for review March 13, 2026 03:44
@aisle-research-bot
Copy link

aisle-research-bot bot commented Mar 13, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Denial of Service via unbounded string splitting in Slack directive parsing
2 🔵 Low Potential DoS via unbounded JSON.parse of Slack blocks in routeReply empty-guard bypass

1. 🟡 Denial of Service via unbounded string splitting in Slack directive parsing

Property Value
Severity Medium
CWE CWE-400
Location src/auto-reply/reply/slack-directives.ts:55-61

Description

parseSlackDirectives builds interactive Slack blocks from directive bodies. The directive body is parsed using String.split() without a limit, which can allocate extremely large arrays and consume CPU/memory for adversarially large strings.

In particular:

  • parseChoices() uses raw.split(",") and only applies .slice(0, maxItems) after splitting, so a body containing a very large number of commas (e.g., "a:a," repeated many times) will create a huge intermediate array.
  • buildSelectBlock() uses raw.split("|") without limiting the number of parts, which can similarly allocate very large arrays if many | characters are present.
  • parseSlackDirectives() iterates over all directive matches and accumulates generatedBlocks and visibleTextParts before enforcing the Slack block cap (50). If a reply contains many directives, the code can do substantial work and allocate large arrays even though it later returns the original payload when the cap is exceeded.

Vulnerable code:

function parseChoices(raw: string, maxItems: number): SlackChoice[] {
  return raw
    .split(",")
    .map((entry) => parseChoice(entry))
    .filter((entry): entry is SlackChoice => Boolean(entry))
    .slice(0, maxItems);
}

While replies are typically agent-generated, user prompt-injection or upstream integrations can induce very large outputs; when enableSlackInteractiveReplies is enabled, this becomes a practical resource-exhaustion vector (CPU and memory) during reply normalization.

Recommendation

Mitigate uncontrolled resource consumption by applying hard limits and avoiding unbounded split() intermediates.

Suggested changes:

  1. Cap input sizes before parsing (defense-in-depth):
const MAX_SLACK_DIRECTIVE_TEXT = 20_000;
if (text.length > MAX_SLACK_DIRECTIVE_TEXT) return payload;
  1. Limit split results at the source (avoid huge arrays):
function parseChoices(raw: string, maxItems: number): SlackChoice[] {
  return raw
    .split(",", maxItems) // limit output size
    .map(parseChoice)
    .filter((entry): entry is SlackChoice => Boolean(entry));
}

function buildSelectBlock(raw: string, index: number): SlackBlock | null {// only need at most 2 parts
  const [firstRaw, secondRaw] = raw.split("|", 2);
  const first = firstRaw?.trim();
  const second = secondRaw?.trim();
  ...
}
  1. Early-abort once you exceed Slack block limits / directive count (avoid work that will be discarded):
if (existingBlocks.length + generatedBlocks.length >= SLACK_MAX_BLOCKS) break; // or return payload

Optionally, enforce a max directives count (e.g., 10) and a max directive body length to keep processing bounded.


2. 🔵 Potential DoS via unbounded JSON.parse of Slack blocks in routeReply empty-guard bypass

Property Value
Severity Low
CWE CWE-400
Location src/auto-reply/reply/route-reply.ts:113-124

Description

routeReply() now attempts to detect Slack blocks before applying the empty-reply guard. For Slack payloads it calls parseSlackBlocksInput() on normalized.channelData.slack.blocks.

  • parseSlackBlocksInput() accepts raw JSON strings and parses them with JSON.parse().
  • There is no input-size limit prior to parsing.
  • While the parsed array length is capped at 50 blocks, that validation happens after JSON.parse(), so a very large JSON string can still cause significant CPU/memory usage.
  • This is newly reachable even when the reply is otherwise empty (text whitespace and no media), because block detection happens before the empty-reply early return.

Vulnerable flow:

  • Input: ReplyPayload.channelData.slack.blocks (can be a string)
  • Parsing sink: JSON.parse(raw) inside parseSlackBlocksInput()
  • Trigger: routeReply() block detection to bypass empty reply guard

Vulnerable code (new call site):

hasSlackBlocks = Boolean(
  parseSlackBlocksInput((normalized.channelData.slack as { blocks?: unknown }).blocks)?.length,
);

Parsing sink:

return JSON.parse(raw);

Recommendation

Add strict size limits (and optionally type restrictions) before attempting to parse Slack blocks JSON strings.

Suggested hardening in parseSlackBlocksInput():

const SLACK_BLOCKS_JSON_MAX_CHARS = 100_000; // pick an appropriate limit

function parseBlocksJson(raw: string) {
  if (raw.length > SLACK_BLOCKS_JSON_MAX_CHARS) {
    throw new Error("blocks JSON too large");
  }
  try {
    return JSON.parse(raw);
  } catch {
    throw new Error("blocks must be valid JSON");
  }
}

export function parseSlackBlocksInput(raw: unknown) {
  if (raw == null) return undefined;
  if (typeof raw === "string") {// optional: require it to start with '[' to avoid parsing huge non-array JSON
    if (!raw.trimStart().startsWith("[")) throw new Error("blocks must be an array");
    return validateSlackBlocksArray(parseBlocksJson(raw));
  }
  return validateSlackBlocksArray(raw);
}

Additionally, consider avoiding JSON-string inputs entirely on internal code paths (require blocks to be an array at boundaries) so you never parse attacker-controlled JSON at runtime.


Analyzed PR: #44607 at commit aa6574d

Last updated on: 2026-03-13T21:29:18Z

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR adds opt-in Slack interactive reply directives ([[slack_buttons: ...]] and [[slack_select: ...]]) that agents can use to emit Slack Block Kit action blocks, gated behind channels.slack.capabilities.interactiveReplies (disabled by default). It mirrors the existing Telegram capabilities pattern, adds schema/type support, wires the capability flag through the reply normalization pipeline, and updates agent prompt hints to reflect the current config.

Key changes:

  • New src/auto-reply/reply/slack-directives.ts — parses directives from reply text and compiles them into Slack actions blocks (buttons and static select menus).
  • New src/slack/interactive-replies.ts — resolves whether the capability is enabled for a given account/config.
  • normalize-reply.ts / reply-dispatcher.ts / route-reply.ts / reply-prefix.ts — thread the enableSlackInteractiveReplies flag through the reply normalization pipeline.
  • Schema and type updates (types.slack.ts, zod-schema.providers-core.ts) add SlackCapabilitiesConfig as a string-array or object union, backward-compatible with the legacy string[] format.
  • Agent prompt hints in extensions/slack/src/channel.ts conditionally surface the directive syntax only when the capability is enabled.

Issues found:

  • Multi-account fallback in isSlackInteractiveRepliesEnabled: When accountId is null/undefined and multiple Slack accounts are configured, the function returns true if any account has interactiveReplies: true. This could inadvertently enable interactive reply compilation for a conversation routed through an account that does not have the capability set.
  • Text visibility with pre-existing blocks: When channelData.slack.blocks is already populated, parseSlackDirectives omits a section block for the cleaned text, leaving it only as a Slack notification/accessibility fallback (not visible in the channel). This is likely intentional but is undocumented in code comments, making it easy to misread as a bug.

Confidence Score: 3/5

  • Safe to merge with low risk if only single Slack accounts are deployed; higher risk in multi-account configurations due to the .some() fallback logic.
  • The feature is well-tested, disabled by default, and backward-compatible. The primary logic concern is the multi-account .some() fallback in isSlackInteractiveRepliesEnabled — in multi-account setups without a resolved accountId, enabling the capability on any one account can bleed into others. All primary call sites pass accountId when available, mitigating this in the common path, but the fallback remains a correctness risk worth addressing before this ships to production multi-account environments.
  • src/slack/interactive-replies.ts — review the .some() fallback semantics for multi-account configurations.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/slack/interactive-replies.ts
Line: 31-34

Comment:
**`.some()` fallback may over-enable interactive replies across accounts**

When `accountId` is `null`/`undefined` and multiple Slack accounts are configured, the function returns `true` if **any** account has `interactiveReplies: true`. This means that if Account A has the capability enabled and Account B does not, a message routed through Account B (with an unresolved `accountId`) would still have Slack directives compiled into blocks — even though Account B's operator explicitly left the capability off.

The primary call sites (`route-reply.ts`, `reply-prefix.ts`, `channel.ts`) all pass `accountId` when it is available, so the fallback should be rare in practice. However, if the `accountId` cannot be resolved (e.g., during certain initialization paths), the overly-broad `.some()` semantics would silently enable the feature for the wrong account.

A safer fallback would be to return `false` (opt-out) when `accountId` is unknown and there are multiple accounts, since `interactiveReplies` is documented as disabled-by-default:

```typescript
// When accountId is unknown and multiple accounts exist, we cannot determine
// which account's config applies — default to disabled.
return accountIds.some((accountId) => {
  const account = resolveSlackAccount({ cfg: params.cfg, accountId });
  return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
});
```

Consider replacing `.some()` with a check that requires all accounts to have the capability (`.every()`), or simply returning `false` in the multi-account-without-accountId case.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/auto-reply/reply/slack-directives.ts
Line: 167-175

Comment:
**Cleaned text is silently dropped from visual rendering when pre-existing blocks are present**

When `existingBlocks.length > 0`, the code intentionally skips creating a section block for `cleanedText` — that text is only returned as `result.text`. In Slack, when a message has `blocks`, the top-level `text` field is used **only** as a notification/accessibility fallback and is **not** displayed in the channel. This means that if an agent writes:

```
"Act now [[slack_buttons: Retry:retry]]"
```

and `channelData.slack.blocks` is already populated (e.g., from a prior Block Kit step), the string `"Act now"` will be invisible to the user in the Slack channel — it will appear only in push notification previews and screen readers.

The current test explicitly covers this path (see `reply-flow.test.ts` "appends Slack interactive blocks to existing slack blocks") and accepts `result.text === "Act now"` without a corresponding section block. This confirms the behavior is intentional, but it's worth documenting explicitly in code or a comment, since the silent drop is non-obvious to callers:

```typescript
// When there are pre-existing blocks, we do NOT inject a section block for
// cleanedText — the caller is responsible for ensuring the text content is
// already represented in existingBlocks. cleanedText is still returned as
// result.text for Slack's notification/accessibility fallback.
if (existingBlocks.length === 0) {
```

Without this comment, a future maintainer may introduce a regression by adding a section block in both branches, duplicating the text. Alternatively, if the lost-text scenario is actually undesirable, consider always prepending a section block when `cleanedText` is non-empty, even when blocks already exist.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: df353a9

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df353a98f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 357fa94893

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@vincentkoc vincentkoc merged commit a976cc2 into main Mar 13, 2026
23 of 31 checks passed
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa6574d6c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +192 to +196
if (block) {
generatedInteractiveBlock = true;
generatedBlocks.push(block);
}
cursor = index + matchText.length;

Choose a reason for hiding this comment

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

P2 Badge Preserve malformed Slack directives in visible reply text

This loop strips every matched directive from cleanedText, even when block generation fails. As a result, if a reply mixes at least one valid directive with a malformed one (for example, an invalid [[slack_select: ...]] choices segment), the malformed directive text is silently dropped instead of remaining visible to the user. That causes content loss and makes directive-format mistakes hard to diagnose; failed directive parses should keep the original matchText in visibleTextParts.

Useful? React with 👍 / 👎.

frankekn pushed a commit to xinhuagu/openclaw that referenced this pull request Mar 14, 2026
* Reply: add Slack interactive directive parser

* Reply: wire Slack directives into normalization

* Reply: cover Slack directive parsing

* Reply: test Slack directive normalization

* Slack: hint interactive reply directives

* Config: add Slack interactive reply capability type

* Config: validate Slack interactive reply capability

* Reply: gate Slack directives behind capability

* Slack: gate interactive reply hints by capability

* Tests: cover Slack interactive reply capability gating

* Changelog: note opt-in Slack interactive replies

* Slack: fix interactive reply review findings

* Slack: harden interactive reply routing and limits

* Slack: harden interactive reply trust and validation
ecochran76 pushed a commit to ecochran76/openclaw that referenced this pull request Mar 14, 2026
* Reply: add Slack interactive directive parser

* Reply: wire Slack directives into normalization

* Reply: cover Slack directive parsing

* Reply: test Slack directive normalization

* Slack: hint interactive reply directives

* Config: add Slack interactive reply capability type

* Config: validate Slack interactive reply capability

* Reply: gate Slack directives behind capability

* Slack: gate interactive reply hints by capability

* Tests: cover Slack interactive reply capability gating

* Changelog: note opt-in Slack interactive replies

* Slack: fix interactive reply review findings

* Slack: harden interactive reply routing and limits

* Slack: harden interactive reply trust and validation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: slack Channel integration: slack maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant