fix(exec): add shared approval runtime#57655
Conversation
🔒 Aisle Security AnalysisWe found 5 potential security issue(s) in this PR:
Vulnerabilities1. 🟠 Credential exfiltration / SSRF via plugin-controlled gatewayUrl in createExecApprovalChannelRuntime
Description
This is dangerous if third-party plugins are not fully trusted:
As a result, a malicious plugin can point Vulnerable code: const client = await createOperatorApprovalsGatewayClient({
config: adapter.cfg,
gatewayUrl: adapter.gatewayUrl,
clientDisplayName: adapter.clientDisplayName,
onEvent: handleGatewayEvent,
// ...
});RecommendationDo not allow untrusted/plugin code to override the gateway URL when the client uses host credentials. Options:
Example (restrict to configured gateway origin): import { buildGatewayConnectionDetails } from "../gateway/call.js";
const { url: configuredUrl } = buildGatewayConnectionDetails({ config: adapter.cfg });
if (adapter.gatewayUrl && new URL(adapter.gatewayUrl).origin !== new URL(configuredUrl).origin) {
throw new Error("gatewayUrl override not permitted");
}
const client = await createOperatorApprovalsGatewayClient({
config: adapter.cfg,
gatewayUrl: configuredUrl,
clientDisplayName: adapter.clientDisplayName,
onEvent: handleGatewayEvent,
});
2. 🟡 Unbounded pending map growth when deliverRequested rejects or hangs in exec approval channel runtime
Description
This creates a denial-of-service risk:
Vulnerable code: pending.set(request.id, entry);
const entries = await adapter.deliverRequested(request);RecommendationEnsure pending entries are always cleaned up (or at least have a TTL) even when delivery fails/hangs. A typical fix is to wrap delivery in pending.set(request.id, entry);
const timeoutMs = Math.max(0, request.expiresAtMs - nowMs());
entry.timeoutId = setTimeout(() => spawn("error handling approval expiration", handleExpired(request.id)), timeoutMs);
entry.timeoutId.unref?.();
let entries: TPending[];
try {
entries = await adapter.deliverRequested(request);
} catch (e) {
clearPendingEntry(request.id);
throw e;
}Additionally consider:
3. 🟡 Chat command injection via unsanitized approvalCommandId in /approve button commands
DescriptionThe exec approval UI helpers build interactive button command strings by directly interpolating
Vulnerable code: return `/approve ${params.approvalCommandId} ${...}`;A related helper RecommendationEnforce a strict allowlist for Example: const APPROVAL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
function normalizeApprovalCommandId(raw: string): string | null {
const id = raw.trim();
if (!id || !APPROVAL_ID_RE.test(id)) return null;
return id;
}
export function buildExecApprovalActionDescriptors(params: {
approvalCommandId: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): ExecApprovalActionDescriptor[] {
const approvalCommandId = normalizeApprovalCommandId(params.approvalCommandId);
if (!approvalCommandId) return [];
// ...
}Additionally, consider rejecting/normalizing custom IDs at the gateway boundary ( 4. 🟡 Unbounded pending-approval tracking allows memory/timer exhaustion via gateway events
DescriptionThe exec/plugin approval channel runtime tracks pending approvals in an unbounded If an attacker can cause many
Vulnerable code: const pending = new Map<string, PendingApprovalEntry<...>>();
...
pending.set(request.id, entry);
...
const timeoutMs = Math.max(0, request.expiresAtMs - nowMs());
const timeoutId = setTimeout(() => {
spawn("error handling approval expiration", handleExpired(request.id));
}, timeoutMs);
...
spawn("error handling approval request", handleRequested(evt.payload as TRequest));RecommendationAdd runtime validation and resource limits before accepting/scheduling a request. Minimum hardening steps:
Example (illustrative): const MAX_PENDING = 1000;
const MAX_ID_LEN = 128;
const MAX_TTL_MS = 15 * 60 * 1000;
function isApprovalRequestPayload(p: unknown): p is { id: string; expiresAtMs: number } {
return (
typeof p === "object" && p !== null &&
typeof (p as any).id === "string" &&
(p as any).id.length > 0 && (p as any).id.length <= MAX_ID_LEN &&
typeof (p as any).expiresAtMs === "number" && Number.isFinite((p as any).expiresAtMs)
);
}
if (evt.event === "exec.approval.requested") {
if (!isApprovalRequestPayload(evt.payload)) return;
if (pending.size >= MAX_PENDING) { /* drop/evict */ return; }
const expiresAtMs = Math.min(evt.payload.expiresAtMs, nowMs() + MAX_TTL_MS);
await handleRequested({ ...evt.payload, expiresAtMs } as TRequest);
}This prevents a single stream of events from creating unbounded memory/timer growth and avoids malformed payloads driving runtime behavior. 5. 🟡 Unvalidated gateway event payload passed to approval adapters
Description
Although the gateway client validates the outer
Vulnerable code: if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
return;
}
...
if (evt.event === "exec.approval.resolved" && eventKinds.has("exec")) {
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
return;
}RecommendationAdd runtime validation for each expected payload type at this trust boundary, and drop/log invalid events. Options:
Example (sketch): import { validateExecApprovalRequestedEventPayload } from "../gateway/protocol/index.js";
const handleGatewayEvent = (evt: EventFrame): void => {
if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
if (!validateExecApprovalRequestedEventPayload(evt.payload)) {
log.warn("dropping invalid exec.approval.requested payload");
return;
}
spawn("error handling approval request", handleRequested(evt.payload));
return;
}
// ... similarly for other event kinds
};Also consider:
Analyzed PR: #57655 at commit Last updated on: 2026-03-30T11:10:21Z |
Greptile SummaryThis PR introduces a shared Key changes:
Confidence Score: 5/5Safe to merge; the only finding is a P2 robustness improvement (adding .catch error logging to gateway event dispatch). All findings are P2 or lower. The logic is correct, tests are solid, the API-baseline regeneration matches the new exports, and the race window in handleRequested is cosmetically possible but practically unreachable. No data loss, security, or runtime-crash risk. src/infra/exec-approval-channel-runtime.ts — specifically the handleGatewayEvent void dispatch pattern.
|
| Filename | Overview |
|---|---|
| src/infra/exec-approval-channel-runtime.ts | New shared runtime factory for exec and plugin approval subscriptions. Covers pending tracking, timeout management, gateway subscription, and the request proxy. Well-structured with good test coverage. Minor gap: gateway event dispatch uses bare void without .catch, silently swallowing deliverRequested/finalizeResolved errors. |
| src/infra/exec-approval-reply.ts | Refactors approval button/action building into exported buildExecApprovalActionDescriptors and adds parseExecApprovalCommandText. Logic is correct; regex handles both allow-always and always aliases with correct alternation ordering, and the empty-approvalCommandId guard is a sensible addition. |
| src/infra/exec-approval-channel-runtime.test.ts | New test file covering the runtime factory: no-op when unconfigured, pending/expiry tracking, gateway request routing, and plugin-event subscription. Coverage is solid for the happy paths. |
| src/infra/exec-approval-reply.test.ts | Extends the existing test suite with cases for the new exported helpers. All round-trip and edge-case assertions look correct. |
| src/plugin-sdk/infra-runtime.ts | Re-exports exec-approval-channel-runtime from the plugin SDK surface. Consistent with the existing re-export pattern in this file. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/infra/exec-approval-channel-runtime.ts
Line: 136-152
Comment:
**Unhandled promise rejections from gateway event dispatch**
All four `void` calls in `handleGatewayEvent` discard the returned Promises without attaching any error handler. If `adapter.deliverRequested`, `adapter.finalizeResolved`, or any synchronous throw inside `handleRequested`/`handleResolved` rejects the Promise, the error is silently swallowed — no log, no metric, no signal. In production this makes debugging approval delivery failures very difficult.
Consider chaining `.catch` to at least surface errors via the subsystem logger:
```suggestion
const handleGatewayEvent = (evt: EventFrame): void => {
if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
void handleRequested(evt.payload as TRequest).catch((err: unknown) => {
log.error(`error handling approval request: ${err instanceof Error ? err.message : String(err)}`);
});
return;
}
if (evt.event === "plugin.approval.requested" && eventKinds.has("plugin")) {
void handleRequested(evt.payload as TRequest).catch((err: unknown) => {
log.error(`error handling approval request: ${err instanceof Error ? err.message : String(err)}`);
});
return;
}
if (evt.event === "exec.approval.resolved" && eventKinds.has("exec")) {
void handleResolved(evt.payload as TResolved).catch((err: unknown) => {
log.error(`error handling approval resolved: ${err instanceof Error ? err.message : String(err)}`);
});
return;
}
if (evt.event === "plugin.approval.resolved" && eventKinds.has("plugin")) {
void handleResolved(evt.payload as TResolved).catch((err: unknown) => {
log.error(`error handling approval resolved: ${err instanceof Error ? err.message : String(err)}`);
});
}
};
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(exec): add shared approval runtime" | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 614c42ac8e
ℹ️ 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".
🔒 Aisle Security AnalysisWe found 4 potential security issue(s) in this PR:
Vulnerabilities1. 🟠 Approval ID injection can flip /approve decision in generated interactive commands
Description
If an attacker can influence the approval request ID (e.g., via the optional
This enables decision spoofing. Example malicious ID:
Vulnerable code: return `/approve ${params.approvalCommandId} ${params.decision === "allow-always" ? "always" : params.decision}`;This is exploitable anywhere the system renders interactive approval actions using attacker-influenced IDs. RecommendationTreat approval IDs as untrusted when building human/interactive command strings.
Example fix (ID validation at render time): const APPROVAL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
export function buildExecApprovalCommandText(params: {
approvalCommandId: string;
decision: ExecApprovalReplyDecision;
}): string {
const id = params.approvalCommandId.trim();
if (!APPROVAL_ID_RE.test(id)) {
throw new Error("Invalid approval id");
}
const decisionToken = params.decision === "allow-always" ? "always" : params.decision;
return `/approve ${id} ${decisionToken}`;
}Also consider updating 2. 🟠 Untrusted gatewayUrl override can exfiltrate stored device token via operator approvals runtime
DescriptionThe exported Because Key security impact:
Vulnerable flow:
Vulnerable code (entry point): const client = await createOperatorApprovalsGatewayClient({
config: adapter.cfg,
gatewayUrl: adapter.gatewayUrl,
clientDisplayName: adapter.clientDisplayName,
onEvent: handleGatewayEvent,
...
});RecommendationTreat Options (choose one or combine):
Example mitigation (deny non-loopback override unless explicitly pinned): import { isLoopbackHost } from "../gateway/net.js";
function assertSafeGatewayUrlOverride(url: string, tlsFingerprint?: string) {
const u = new URL(url);
if (!isLoopbackHost(u.hostname) && !tlsFingerprint) {
throw new Error("Refusing non-loopback gatewayUrl override without TLS pinning");
}
}
// before createOperatorApprovalsGatewayClient(...)
if (adapter.gatewayUrl) {
assertSafeGatewayUrlOverride(adapter.gatewayUrl, adapter.cfg.gateway?.remote?.tlsFingerprint);
}Also consider changing 3. 🟡 Denial-of-service risk from unbounded pending approvals Map and per-request timers
DescriptionThe new
If an attacker can trigger many approval requests (e.g., by abusing the gateway method RecommendationAdd resource limits and defensive bounds to prevent untrusted or compromised sources from exhausting memory/timers. Suggested mitigations (combine as appropriate):
const MAX_PENDING = 1000;
if (pending.size >= MAX_PENDING) {
log.error(`too many pending approvals (${pending.size}), dropping ${request.id}`);
return;
}
const MAX_TIMEOUT_MS = 30 * 60 * 1000; // 30m
const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(0, request.expiresAtMs - nowMs()));
4. 🟡 Unvalidated gateway event payload cast to approval request/resolution objects
Description
Vulnerable code: if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
return;
}
...
if (evt.event === "exec.approval.resolved" && eventKinds.has("exec")) {
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
return;
}RecommendationValidate Example using existing AJV validators from import {
validateExecApprovalRequestParams,
validateExecApprovalResolveParams,
validatePluginApprovalRequestParams,
validatePluginApprovalResolveParams,
formatValidationErrors,
} from "../gateway/protocol/index.js";
const handleGatewayEvent = (evt: EventFrame): void => {
if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
if (!validateExecApprovalRequestParams(evt.payload)) {
log.error(`invalid exec approval request payload: ${formatValidationErrors(validateExecApprovalRequestParams.errors)}`);
return;
}
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
return;
}
if (evt.event === "exec.approval.resolved" && eventKinds.has("exec")) {
if (!validateExecApprovalResolveParams(evt.payload)) {
log.error(`invalid exec approval resolved payload: ${formatValidationErrors(validateExecApprovalResolveParams.errors)}`);
return;
}
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
return;
}
// ...same for plugin events
};If Analyzed PR: #57655 at commit Last updated on: 2026-03-30T10:51:37Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b0c746d726
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9d0d99c713
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 454943a1a5
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e7e27b4994
ℹ️ 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".
| gatewayClient?.stop(); | ||
| gatewayClient = null; |
There was a problem hiding this comment.
Stop the client captured before shutdown race
stop() operates on the mutable gatewayClient field after setting started = false, so a concurrent start() can create and assign a new client before this call runs; in that interleaving, shutdown stops and nulls the newly started client instead of the old one, leaving a restart attempt disconnected. Capture the current client reference at the beginning of stop() and stop that captured instance so overlapping stop/start calls cannot tear down the wrong connection.
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
Issues
Related: #57154
Related: #57339
Related: #57460
Testing