fix(exec): restore channel approval routing#57649
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 Approval command sender allowlist bypass when channel approval auth adapter returns allow-by-default
DescriptionThe Data flow / reasoning:
Impact:
Vulnerable code (bypass condition): const hasExplicitApprovalAuthorization =
(execApprovalAuthorization.explicit && execApprovalAuthorization.authorized) ||
(pluginApprovalAuthorization.explicit && pluginApprovalAuthorization.authorized);
if (!params.command.isAuthorizedSender && !hasExplicitApprovalAuthorization) {
return { shouldContinue: false };
}Why this can happen (allow-by-default auth adapter): const approvers = params.resolveApprovers({ cfg, accountId });
if (approvers.length === 0) {
return { authorized: true } as const;
}RecommendationDo not allow
Example fix (minimal change: require an explicit deny/allow policy to be configured): // src/infra/channel-approval-auth.ts
const result = plugin?.auth?.authorizeActorAction?.(...);
if (!result) return { authorized: true, explicit: false };
// If plugin returns allow without a reason/without policy, don’t treat as explicit.
if (result.authorized === true && result.reason === undefined) {
return { ...result, explicit: false };
}
return { ...result, explicit: true };Better: add a dedicated field: type AuthzResult = { authorized: boolean; reason?: string; policyConfigured: boolean };Then in 2. 🟡 Potential denial-of-service via unbounded gateway client creation and no handshake timeout in Telegram approval resolver
Description
Impact:
Vulnerable code: const ready = new Promise<void>((resolve, reject) => { /* ... */ });
const gatewayClient = await createOperatorApprovalsGatewayClient({
onHelloOk: () => markReady(),
onConnectError: (err) => failReady(err),
onClose: (code, reason) => failReady(new Error(`gateway closed (${code}): ${reason}`)),
});
gatewayClient.start();
await ready; // no timeoutRecommendationAdd a bounded timeout for the pre-ready handshake and consider reusing a shared gateway client (or a small pool) rather than creating a new one per callback. Example (timeout + cleanup): const READY_TIMEOUT_MS = 10_000;
const readyWithTimeout = Promise.race([
ready,
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error("gateway handshake timeout")), READY_TIMEOUT_MS)
),
]);
gatewayClient.start();
await readyWithTimeout;Also consider rate limiting approval callbacks per sender/chat and ensuring 3. 🟡 Telegram bot processes self-authored group messages (potential feedback loop / DoS)
DescriptionIn
If the Telegram Bot API delivers updates for the bot’s own messages in groups (common for many bot setups), this can cause:
Vulnerable code: const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
...
// only ignored for DMs now
if (!isGroup && normalizedMsg.from?.id != null && normalizedMsg.from.id === ctx.me?.id) {
return;
}
await handleInboundMessageLike({ ... });RecommendationReinstate a general self-message suppression for Example fix: const botId = ctx.me?.id;
if (typeof botId === "number" && normalizedMsg.from?.id === botId) {
return; // ignore bot's own messages in all chats
}If self-authored group messages must be supported for a specific feature, gate it behind a dedicated config flag and add additional safeguards (e.g., tag outbound bot messages with an unambiguous marker and drop them on ingress, or only allow a strict subset of synthetic/internal commands). Analyzed PR: #57649 at commit Last updated on: 2026-03-30T15:45:29Z |
Greptile SummaryThis PR unifies the Discord and Telegram exec/plugin approval runtime into a single shared The refactor is architecturally clean and the new shared runtime is well-tested. One logic error was found in the newly added
Confidence Score: 4/5Safe to merge after fixing the misleading 'not authorized' error message in the plugin-only approval path. One P1 logic bug: a sender who is plugin-authorized receives a 'not authorized' reply when their approval has expired, because pluginApprovalAuthorization.reason is undefined when authorized === true and the null-coalesce falls through to the wrong default string. The remaining findings are P2 (dead field, inconsistent error-detection pattern). The shared runtime itself, the Telegram handler, and the Discord handler look correct. src/auto-reply/reply/commands-approve.ts — the else-if (pluginApprovalAuthorization.authorized) not-found error branch.
|
| Filename | Overview |
|---|---|
| src/infra/exec-approval-channel-runtime.ts | New shared runtime factory — clean implementation of pending-entry tracking, timeout management, and gateway subscription. |
| src/auto-reply/reply/commands-approve.ts | P1 bug: misleading 'not authorized' error returned when a plugin-authorized sender's approval has expired. |
| extensions/discord/src/monitor/exec-approvals.ts | Migrated to shared runtime; unused timeoutId field remains in PendingApproval type. |
| extensions/telegram/src/exec-approvals-handler.ts | Migrated to shared runtime; approval delivery and cleanup logic look correct. |
| extensions/telegram/src/exec-approval-resolver.ts | Button-callback resolution uses regex-only not-found detection instead of the more robust isApprovalNotFoundError. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/auto-reply/reply/commands-approve.ts
Line: 241-257
Comment:
**Misleading "not authorized" error for expired plugin approval**
In this `else if (pluginApprovalAuthorization.authorized)` branch the sender is confirmed to be plugin-authorized (the branch is only entered when `authorized === true`). Because `reason` is conventionally `undefined` when `authorized` is `true`, hitting the `isApprovalNotFoundError` catch falls through to:
```ts
text: pluginApprovalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
```
…which always resolves to `"❌ You are not authorized to approve this request."` — exactly the wrong message for a user whose plugin approval has simply expired.
A concrete repro path: sender is authorized for plugin approvals only, types `/approve plugin:<uuid> allow-once`, the request expires in the brief window between parse and the gateway round-trip → they receive "not authorized" instead of "not found / expired".
The `pluginApprovalAuthorization.reason` fallback makes sense as the reply text only when `authorized` is `false`; for the not-found case it should echo the actual error.
```suggestion
} else if (pluginApprovalAuthorization.authorized) {
try {
await callApprovalMethod("plugin.approval.resolve");
} catch (err) {
if (isApprovalNotFoundError(err)) {
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
} else {
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/discord/src/monitor/exec-approvals.ts
Line: 70-74
Comment:
**Unused `timeoutId` field in `PendingApproval`**
`timeoutId` was part of the old per-channel timeout management. Now that timeout lifecycle is handled entirely by the shared `ExecApprovalChannelRuntime`, this field is never written to or read from. Removing it avoids confusion about ownership.
```suggestion
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
};
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/telegram/src/exec-approval-resolver.ts
Line: 69-74
Comment:
**Fragile not-found detection: regex-only vs. error-code check in `commands-approve.ts`**
The fallback from `exec.approval.resolve` → `plugin.approval.resolve` here relies solely on the regex `/unknown or expired approval id/i`. The `isApprovalNotFoundError` helper introduced in `commands-approve.ts` in this same PR also checks structured gateway error codes (`ErrorCodes.APPROVAL_NOT_FOUND`, `ErrorCodes.INVALID_REQUEST` with a `details.reason`), making it more resilient to gateway message changes.
Consider extracting `isApprovalNotFoundError` (or an equivalent check) into a shared utility and using it here too, so both fallback paths stay in sync.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(exec): allow telegram target approva..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08d30db111
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
686e553 to
728f0ac
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 728f0acabf
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
728f0ac to
933d9d8
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0fae95b10a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
0fae95b to
c625a3c
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c625a3c306
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const hasExplicitApprovalAuthorization = | ||
| (execApprovalAuthorization.explicit && execApprovalAuthorization.authorized) || | ||
| (pluginApprovalAuthorization.explicit && pluginApprovalAuthorization.authorized); | ||
| if ( | ||
| !params.command.isAuthorizedSender && |
There was a problem hiding this comment.
Keep sender allowlist enforced when approver policy is empty
This gate now treats any explicit channel auth success as a replacement for command.isAuthorizedSender, but several channel auth adapters return success when no approvers are configured (fresh evidence: createResolvedApproverActionAuthAdapter returns { authorized: true } on empty approver lists in src/plugin-sdk/approval-auth-helpers.ts:30-33). In that common configuration, blocked senders (isAuthorizedSender=false) can still run /approve and resolve approvals, which is a privilege escalation compared to the previous unconditional sender gate.
Useful? React with 👍 / 👎.
|
Superseded by the reviewable split here:
Closing this stacked PR so the approval work only has two active review surfaces: one main approval/runtime/routing/auth PR and one shell |
Summary
/approveroutingIssues
Fixes #57154
Fixes #57339
Related: #57460
Testing