Slack: add opt-in interactive reply directives#44607
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Denial of Service via unbounded string splitting in Slack directive parsing
Description
In particular:
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 RecommendationMitigate uncontrolled resource consumption by applying hard limits and avoiding unbounded Suggested changes:
const MAX_SLACK_DIRECTIVE_TEXT = 20_000;
if (text.length > MAX_SLACK_DIRECTIVE_TEXT) return payload;
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();
...
}
if (existingBlocks.length + generatedBlocks.length >= SLACK_MAX_BLOCKS) break; // or return payloadOptionally, 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
Description
Vulnerable flow:
Vulnerable code (new call site): hasSlackBlocks = Boolean(
parseSlackBlocksInput((normalized.channelData.slack as { blocks?: unknown }).blocks)?.length,
);Parsing sink: return JSON.parse(raw);RecommendationAdd strict size limits (and optionally type restrictions) before attempting to parse Slack blocks JSON strings. Suggested hardening in 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 Analyzed PR: #44607 at commit Last updated on: 2026-03-13T21:29:18Z |
Greptile SummaryThis PR adds opt-in Slack interactive reply directives ( Key changes:
Issues found:
Confidence Score: 3/5
Prompt To Fix All With AIThis 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 |
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
| if (block) { | ||
| generatedInteractiveBlock = true; | ||
| generatedBlocks.push(block); | ||
| } | ||
| cursor = index + matchText.length; |
There was a problem hiding this comment.
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 👍 / 👎.
* 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
* 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
Summary
[[slack_buttons: ...]]and[[slack_select: ...]]reply directives behindchannels.slack.capabilities.interactiveReplies, disabled by default, with agent prompt hints that reflect the current config.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
[[slack_buttons: Label:value, Other:other]]whenchannels.slack.capabilities.interactiveRepliesis enabled.[[slack_select: Placeholder | Label:value, Other:other]]whenchannels.slack.capabilities.interactiveRepliesis enabled.Security Impact (required)
NoNoNoNoNoRepro + Verification
Environment
channels.slack.capabilities.interactiveReplies=trueSteps
[[slack_buttons: ...]]or[[slack_select: ...]]with Slack interactive replies disabled.channels.slack.capabilities.interactiveReplies=true.channelData.slack.blocks.Expected
Actual
Evidence
Human Verification (required)
Review Conversations
Compatibility / Migration
YesOptionalNoFailure Recovery (if this breaks)
channels.slack.capabilities.interactiveRepliesunset or set it tofalse.Risks and Mitigations