Skip to content

fix(exec): restore channel approval routing#57649

Closed
scoootscooob wants to merge 3 commits into
openclaw:codex/exec-approval-core-runtimefrom
scoootscooob:codex/exec-approval-foundation
Closed

fix(exec): restore channel approval routing#57649
scoootscooob wants to merge 3 commits into
openclaw:codex/exec-approval-core-runtimefrom
scoootscooob:codex/exec-approval-foundation

Conversation

@scoootscooob

@scoootscooob scoootscooob commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • wire Discord and Telegram back onto the shared approval runtime for delivery, callback resolution, and typed /approve routing
  • restore Telegram callback resolution, target-recipient approval handling, and channel-native approval button behavior
  • restore Discord plugin approval delivery and resolution on top of the shared runtime

Issues

Fixes #57154
Fixes #57339
Related: #57460

Testing

  • inherited from the stacked validation for this branch family, including:
    • pnpm test -- src/infra/channel-approval-auth.test.ts src/auto-reply/reply/commands.test.ts extensions/telegram/src/exec-approval-resolver.test.ts extensions/telegram/src/bot.test.ts extensions/telegram/src/exec-approvals-handler.test.ts src/infra/exec-approval-channel-runtime.test.ts extensions/discord/src/monitor/exec-approvals.test.ts
    • live gateway smoke: verified exec approval allow/duplicate/expire and plugin approval allow/duplicate against the local gateway on this branch family

@aisle-research-bot

aisle-research-bot Bot commented Mar 30, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Approval command sender allowlist bypass when channel approval auth adapter returns allow-by-default
2 🟡 Medium Potential denial-of-service via unbounded gateway client creation and no handshake timeout in Telegram approval resolver
3 🟡 Medium Telegram bot processes self-authored group messages (potential feedback loop / DoS)
1. 🟠 Approval command sender allowlist bypass when channel approval auth adapter returns allow-by-default
Property Value
Severity High
CWE CWE-285
Location src/auto-reply/reply/commands-approve.ts:150-161

Description

The /approve command can now be executed by senders who are otherwise blocked by the channel allowlist (params.command.isAuthorizedSender === false) if the channel plugin implements authorizeActorAction and returns an allow-by-default decision.

Data flow / reasoning:

  • handleApproveCommand previously hard-blocked when !params.command.isAuthorizedSender.
  • New logic allows bypassing that block when hasExplicitApprovalAuthorization is true.
  • hasExplicitApprovalAuthorization becomes true when resolveApprovalCommandAuthorization(...).explicit && authorized.
  • resolveApprovalCommandAuthorization sets explicit: true whenever a plugin returns any result (even { authorized: true }).
  • Several channel plugins use createResolvedApproverActionAuthAdapter, which returns { authorized: true } when no approvers are configured (i.e., resolveApprovalApprovers(...) returns an empty list). This makes the authorization decision “explicit” and “authorized”, thereby bypassing the sender allowlist check.

Impact:

  • In channels where /approve messages can still arrive from non-allowlisted senders (e.g., public Slack/Discord/other channel surfaces), a blocked sender can submit approvals (exec and/or plugin) when no approvers policy is configured.
  • This is a privilege/boundary regression: “blocked from chat access” no longer implies “blocked from approving”.

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;
}

Recommendation

Do not allow authorizeActorAction to bypass isAuthorizedSender when the channel’s approval policy is effectively “not configured”. Options:

  1. Treat allow-by-default results as non-explicit in resolveApprovalCommandAuthorization.

    • Extend the adapter contract to return an explicit flag (or policyConfigured) and propagate it.
  2. Gate the bypass on an affirmative configured approver policy.

    • Example (conceptual): only bypass when the plugin indicates approvers exist.

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 /approve, only bypass isAuthorizedSender when policyConfigured && authorized.

2. 🟡 Potential denial-of-service via unbounded gateway client creation and no handshake timeout in Telegram approval resolver
Property Value
Severity Medium
CWE CWE-400
Location extensions/telegram/src/exec-approval-resolver.ts:38-80

Description

resolveTelegramExecApproval creates a new gateway client per Telegram callback and waits for a custom ready promise with no timeout.

Impact:

  • If the gateway never invokes onHelloOk, onConnectError, or onClose (e.g., network stall, bug, partial connection), await ready can hang indefinitely.
  • Each incoming approval callback triggers a fresh client (createOperatorApprovalsGatewayClient + .start()), so repeated callbacks can accumulate stuck connections/promises, causing resource exhaustion (sockets, memory, event loop pressure) and degraded availability.

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 timeout

Recommendation

Add 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 stopAndWait() is invoked when the timeout fires (e.g., in a catch/finally).

3. 🟡 Telegram bot processes self-authored group messages (potential feedback loop / DoS)
Property Value
Severity Medium
CWE CWE-400
Location extensions/telegram/src/bot-handlers.runtime.ts:1764-1784

Description

In bot-handlers.runtime.ts, the global self-message guard was removed and replaced with a DM-only check:

  • Previously, all self-authored Telegram messages were ignored.
  • Now, self-authored messages are only ignored when !isGroup.
  • As a result, in groups/supergroups the bot may feed its own outbound messages back into the inbound pipeline (handleInboundMessageLikeprocessInboundMessageprocessMessagedispatchTelegramMessage).

If the Telegram Bot API delivers updates for the bot’s own messages in groups (common for many bot setups), this can cause:

  • Feedback loops (bot responds to itself repeatedly)
  • Unintended command/control-message handling if the bot outputs text beginning with / or otherwise triggers control-command parsing
  • Resource exhaustion/DoS due to repeated LLM calls and message sends

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({ ... });

Recommendation

Reinstate a general self-message suppression for bot.on("message") (and any other inbound handlers where bot-authored updates can appear), unless there is an explicit, well-audited reason to allow self-authored group messages.

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 c625a3c

Last updated on: 2026-03-30T15:45:29Z

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: discord Channel integration: discord channel: telegram Channel integration: telegram size: XL maintainer Maintainer-authored PR labels Mar 30, 2026
@greptile-apps

greptile-apps Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR unifies the Discord and Telegram exec/plugin approval runtime into a single shared ExecApprovalChannelRuntime — moving subscription, pending-entry tracking, timeout management, and gateway plumbing out of each channel's bespoke approval loop into one reusable factory. Both channels now implement a thin ExecApprovalChannelRuntimeAdapter and delegate lifecycle events to the core. It also restores Telegram button-callback and typed /approve resolution for configured approvers, and wires plugin approvals through the same shared path.

The refactor is architecturally clean and the new shared runtime is well-tested. One logic error was found in the newly added /approve command routing:

  • src/auto-reply/reply/commands-approve.ts (L241–257) — In the else if (pluginApprovalAuthorization.authorized) branch the approval-not-found error handler falls back to pluginApprovalAuthorization.reason ?? \"❌ You are not authorized to approve this request.\". Because reason is undefined when authorized === true, a sender who is legitimately plugin-authorized but whose approval has just expired receives a misleading "not authorized" reply instead of a "not found / expired" one.
  • extensions/discord/src/monitor/exec-approvals.ts — The PendingApproval type retains a timeoutId?: NodeJS.Timeout field that is never set or read now that timeouts live in the shared runtime.
  • extensions/telegram/src/exec-approval-resolver.ts — The exec→plugin fallback uses a raw regex to detect "not found" errors while commands-approve.ts uses the more robust isApprovalNotFoundError (checks error codes first). Consider unifying the detection logic.

Confidence Score: 4/5

Safe 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.

Important Files Changed

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

Comment thread src/auto-reply/reply/commands-approve.ts
Comment thread extensions/discord/src/monitor/exec-approvals.ts
Comment thread extensions/telegram/src/exec-approval-resolver.ts
@scoootscooob scoootscooob changed the title fix(exec): unify channel approval runtime fix(exec): restore channel approval routing Mar 30, 2026
@scoootscooob scoootscooob changed the base branch from main to codex/exec-approval-core-runtime March 30, 2026 10:40

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

Copy link
Copy Markdown

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: 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".

Comment thread src/auto-reply/reply/commands-approve.ts
Comment thread extensions/telegram/src/exec-approval-resolver.ts Outdated
@scoootscooob scoootscooob force-pushed the codex/exec-approval-foundation branch from 686e553 to 728f0ac Compare March 30, 2026 11:00
@openclaw-barnacle openclaw-barnacle Bot removed the docs Improvements or additions to documentation label Mar 30, 2026

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

Copy link
Copy Markdown

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: 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".

Comment thread src/infra/exec-approval-channel-runtime.ts Outdated
Comment thread src/infra/exec-approval-channel-runtime.ts Outdated
@scoootscooob scoootscooob force-pushed the codex/exec-approval-foundation branch from 728f0ac to 933d9d8 Compare March 30, 2026 11:09

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

Copy link
Copy Markdown

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: 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".

Comment thread extensions/telegram/src/bot-handlers.runtime.ts Outdated
@scoootscooob scoootscooob force-pushed the codex/exec-approval-foundation branch from 0fae95b to c625a3c Compare March 30, 2026 15:40

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

Copy link
Copy Markdown

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: 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".

Comment on lines +150 to +154
const hasExplicitApprovalAuthorization =
(execApprovalAuthorization.explicit && execApprovalAuthorization.authorized) ||
(pluginApprovalAuthorization.explicit && pluginApprovalAuthorization.authorized);
if (
!params.command.isAuthorizedSender &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

@scoootscooob

Copy link
Copy Markdown
Contributor Author

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 /approve hardening PR.

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

Labels

channel: discord Channel integration: discord channel: telegram Channel integration: telegram maintainer Maintainer-authored PR size: XL

Projects

None yet

1 participant