security: prevent requester self-approval in exec.approval.resolve#48149
security: prevent requester self-approval in exec.approval.resolve#48149kn1ghtc wants to merge 7 commits into
Conversation
A prompt-injected agent could call exec.approval.request to register a dangerous command and then immediately call exec.approval.resolve with "allow-once" to execute it, bypassing human-in-the-loop oversight. The ExecApprovalRecord already captures requestedByConnId on the request side, but the resolve handler never checked it, leaving the field unused despite the aspirational comment "Used to prevent other clients from replaying an approval id". This commit enforces the separation: if the WebSocket connection that created the approval request is the same connection attempting to resolve it, the resolve is rejected with INVALID_REQUEST. Attack scenario blocked: 1. Malicious/compromised agent sends exec.approval.request (connId=A) 2. Same agent immediately sends exec.approval.resolve allow-once (connId=A) 3. Previously: command executed without human review 4. Now: INVALID_REQUEST "requester cannot approve their own exec request" Fixes: CRITICAL-02 (exec approval self-approval / cross-client hijacking)
Greptile SummaryThis PR introduces a connection-identity check in the Issues found:
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/gateway/server-methods/exec-approval.ts
Line: 318-332
Comment:
**Self-approval block also rejects `deny` decisions from the requester**
The guard fires for all three decisions (`allow-once`, `allow-always`, and `deny`). Only `allow-once` and `allow-always` represent the actual security threat — they grant execution rights. Blocking the requester from submitting a `deny` decision on their own request prevents a legitimate cancellation flow (e.g., an agent that determines the command is no longer needed and wants to withdraw the pending request).
The change in behavior means pending requests from any agent can no longer be self-cancelled; they must time out instead. If any callers rely on self-denial to clean up requests promptly, they will silently break.
The check should be scoped to decisions that actually grant execution:
```typescript
if (
(decision === "allow-once" || decision === "allow-always") &&
snapshot?.requestedByConnId != null &&
client?.connId != null &&
client.connId === snapshot.requestedByConnId
) {
```
Additionally, the error message `"requester cannot approve their own exec request"` is semantically incorrect when the rejected decision is `deny`, which is not an approval.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: fb35d46 |
| if ( | ||
| snapshot?.requestedByConnId != null && | ||
| client?.connId != null && | ||
| client.connId === snapshot.requestedByConnId | ||
| ) { | ||
| respond( | ||
| false, | ||
| undefined, | ||
| errorShape( | ||
| ErrorCodes.INVALID_REQUEST, | ||
| "requester cannot approve their own exec request", | ||
| ), | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Self-approval block also rejects deny decisions from the requester
The guard fires for all three decisions (allow-once, allow-always, and deny). Only allow-once and allow-always represent the actual security threat — they grant execution rights. Blocking the requester from submitting a deny decision on their own request prevents a legitimate cancellation flow (e.g., an agent that determines the command is no longer needed and wants to withdraw the pending request).
The change in behavior means pending requests from any agent can no longer be self-cancelled; they must time out instead. If any callers rely on self-denial to clean up requests promptly, they will silently break.
The check should be scoped to decisions that actually grant execution:
if (
(decision === "allow-once" || decision === "allow-always") &&
snapshot?.requestedByConnId != null &&
client?.connId != null &&
client.connId === snapshot.requestedByConnId
) {Additionally, the error message "requester cannot approve their own exec request" is semantically incorrect when the rejected decision is deny, which is not an approval.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/exec-approval.ts
Line: 318-332
Comment:
**Self-approval block also rejects `deny` decisions from the requester**
The guard fires for all three decisions (`allow-once`, `allow-always`, and `deny`). Only `allow-once` and `allow-always` represent the actual security threat — they grant execution rights. Blocking the requester from submitting a `deny` decision on their own request prevents a legitimate cancellation flow (e.g., an agent that determines the command is no longer needed and wants to withdraw the pending request).
The change in behavior means pending requests from any agent can no longer be self-cancelled; they must time out instead. If any callers rely on self-denial to clean up requests promptly, they will silently break.
The check should be scoped to decisions that actually grant execution:
```typescript
if (
(decision === "allow-once" || decision === "allow-always") &&
snapshot?.requestedByConnId != null &&
client?.connId != null &&
client.connId === snapshot.requestedByConnId
) {
```
Additionally, the error message `"requester cannot approve their own exec request"` is semantically incorrect when the rejected decision is `deny`, which is not an approval.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Pull request overview
Adds a security guard in the Gateway exec-approval resolution path to prevent the same connection that requested an exec approval from also resolving (approving/denying) it, reducing risk of immediate self-approval by an injected/automated requester.
Changes:
- Block
exec.approval.resolvewhenclient.connIdmatches the pending approval’srequestedByConnId. - Return an
INVALID_REQUESTerror when a self-approval attempt is detected.
| // Security: prevent self-approval — the connection that submitted the request | ||
| // cannot also resolve it. This blocks prompt-injected agents from immediately | ||
| // approving their own dangerous command requests without human oversight. | ||
| if ( | ||
| snapshot?.requestedByConnId != null && | ||
| client?.connId != null && | ||
| client.connId === snapshot.requestedByConnId | ||
| ) { | ||
| respond( | ||
| false, | ||
| undefined, | ||
| errorShape( | ||
| ErrorCodes.INVALID_REQUEST, | ||
| "requester cannot approve their own exec request", | ||
| ), |
| // Security: prevent self-approval — the connection that submitted the request | ||
| // cannot also resolve it. This blocks prompt-injected agents from immediately | ||
| // approving their own dangerous command requests without human oversight. | ||
| if ( | ||
| snapshot?.requestedByConnId != null && | ||
| client?.connId != null && | ||
| client.connId === snapshot.requestedByConnId | ||
| ) { | ||
| respond( | ||
| false, | ||
| undefined, | ||
| errorShape( | ||
| ErrorCodes.INVALID_REQUEST, | ||
| "requester cannot approve their own exec request", | ||
| ), | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fb35d46f9d
ℹ️ 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".
| snapshot?.requestedByConnId != null && | ||
| client?.connId != null && | ||
| client.connId === snapshot.requestedByConnId |
There was a problem hiding this comment.
Match resolver against requester device identity
The new guard only compares client.connId to snapshot.requestedByConnId, but connId is regenerated on every WebSocket reconnect (src/gateway/server/ws-connection.ts:119), so the same compromised requester can immediately open a second connection and still call exec.approval.resolve on its own pending request. Because requestedByDeviceId/requestedByClientId are already captured on request creation, this check is currently bypassable by a reconnect and does not enforce the intended human-separation boundary.
Useful? React with 👍 / 👎.
| if ( | ||
| snapshot?.requestedByConnId != null && | ||
| client?.connId != null && | ||
| client.connId === snapshot.requestedByConnId | ||
| ) { |
There was a problem hiding this comment.
Allow requester to deny its own pending approval
This block runs before decision-specific handling, so it rejects requester-originated deny decisions along with allow-*. In the request/wait flow, that means a requester can no longer cancel its own pending approval and must wait for timeout expiry, which adds avoidable latency and leaves stale pending entries until expiration even when the caller explicitly tries to abort.
Useful? React with 👍 / 👎.
|
Review: security fix for exec.approval self-approval bypass Verified the vulnerability exists on current main - Traced the connId lifecycle: Changes requested before merge:
This touches security-critical CODEOWNERS paths - flagging for @vincentkoc or @theonejvo review. |
- Replace connId-based identity matching with deviceId/clientId which are stable across WebSocket reconnections - Wrap self-approval check in `if (decision !== "deny")` to allow requesters to cancel their own pending approvals - Add null checks to prevent false positives when identity fields are absent (e.g. plain operator connections without device metadata)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 057982a0c3
ℹ️ 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 sameClient = | ||
| snapshot?.requestedByClientId != null && | ||
| client?.connect?.client?.id != null && | ||
| client.connect.client.id === snapshot.requestedByClientId; | ||
| if (sameDevice || sameClient) { |
There was a problem hiding this comment.
Use instance identity instead of shared client.id for self-approval
sameClient is derived from connect.client.id, but that field is a shared client-family enum (cli, openclaw-macos, etc.) rather than a unique requester identity (src/gateway/protocol/client-info.ts:1-14). Because this guard rejects when sameDevice || sameClient, any approver using the same client type is blocked even from a different device/session, so legitimate flows (for example CLI requester + separate CLI operator) now fail with "requester cannot approve their own exec request" and remain pending until timeout.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6525aeffbd
ℹ️ 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".
| snapshot?.requestedByClientId != null && | ||
| client?.connect?.client?.id != null && | ||
| client.connect.client.id === snapshot.requestedByClientId; | ||
| if (sameDevice || sameClient) { |
There was a problem hiding this comment.
Avoid rejecting approvals from shared host device identity
The sameDevice || sameClient rejection now treats any resolver on the same host identity as the original requester, but GatewayClient defaults to a shared loadOrCreateDeviceIdentity() state file (src/infra/device-identity.ts), so independent operator clients can legitimately share device.id. That makes normal approval flows fail when the request path and approval path run on the same machine (for example, requester via src/agents/tools/gateway.ts and Discord/Telegram approvers via src/gateway/operator-approvals-client.ts), returning requester cannot approve their own exec request and leaving requests to timeout. Fresh evidence: both the requester and extension approver clients resolve to the same default identity source while this condition blocks on sameDevice.
Useful? React with 👍 / 👎.
… check Address P1 review feedback: - deviceId is a host-level identity shared across all operator clients on the same machine — using it blocks legitimate approvers on the same host. - clientId is a client-family enum (e.g. "cli", "openclaw-macos") shared across all instances of the same client type — using it blocks all approvers using the same client. Revert to connId-based matching which is the most specific identity available per WebSocket connection. Document the reconnect-bypass trade-off and the rationale for not using broader identifiers. Also allows self-deny so requesters can cancel their own pending approvals (already guarded by `decision !== "deny"`).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d8fb545c40
ℹ️ 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".
| @@ -156,8 +156,6 @@ export function createExecApprovalHandlers( | |||
| }; | |||
| const record = manager.create(request, timeoutMs, explicitId); | |||
| record.requestedByConnId = client?.connId ?? null; | |||
There was a problem hiding this comment.
Restore requester device metadata on approval records
Only requestedByConnId is now stored on new approvals, but sanitizeSystemRunParamsForForwarding validates replay binding by requestedByDeviceId first and falls back to strict connId matching when that field is absent (src/gateway/node-invoke-system-run-approval.ts:192-207). This means approvals requested on one socket can no longer be consumed from a second connection on the same device (the intended per-call flow in src/gateway/server.node-invoke-approval-bypass.test.ts:345-359), and valid runs fail with approval id not valid for this client after this change.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a1276ed232
ℹ️ 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".
| @@ -161,8 +161,6 @@ export function createExecApprovalHandlers( | |||
| }; | |||
| const record = manager.create(request, timeoutMs, explicitId); | |||
| record.requestedByConnId = client?.connId ?? null; | |||
There was a problem hiding this comment.
Preserve requester device metadata on approval records
node.invoke approval replay validation binds to requestedByDeviceId first and only falls back to strict connId matching when device metadata is missing (src/gateway/node-invoke-system-run-approval.ts:192-207). This change stops populating requestedByDeviceId, so approvals created on one socket can no longer be consumed from another socket on the same device (including normal per-call gateway clients), which causes valid runs to fail as approval id not valid for this client.
Useful? React with 👍 / 👎.
| const sameConn = | ||
| snapshot?.requestedByConnId != null && | ||
| client?.connId != null && | ||
| client.connId === snapshot.requestedByConnId; |
There was a problem hiding this comment.
Enforce self-approval checks on stable requester identity
The new guard blocks only when resolver and requester share the same connId, but requester identity in this codebase is not connection-stable: gateway tool calls create a new GatewayClient/WebSocket per RPC (src/gateway/call.ts:819-872). That means the same requester can issue exec.approval.request and then immediately exec.approval.resolve on the next call with a new connId, bypassing this protection. Fresh evidence: this bypass is the default callGatewayTool execution path, so no special reconnect race is needed.
Useful? React with 👍 / 👎.
Review Status Summary & Review RequestCI StatusOn code commit Review Feedback Addressed
Code Changes Summary (commit
|
Covers the security guard that prevents the requesting connection from approving its own exec request (connId-based matching): - Rejects allow-once from the same connection (self-approval blocked) - Rejects allow-always from the same connection (self-approval blocked) - Permits self-deny so requester can cancel their own request - Allows approval from a different connection - No blocking when original request has no connId recorded - No blocking when approver client has no connId - No blocking when approver client is null
Extract resolve mock as a standalone spy variable instead of accessing it as mgr.resolve in assertions. Use `as any` for handler invocation to avoid partial mock type mismatches.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad3c16d157
ℹ️ 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".
| @@ -161,8 +161,6 @@ export function createExecApprovalHandlers( | |||
| }; | |||
| const record = manager.create(request, timeoutMs, explicitId); | |||
| record.requestedByConnId = client?.connId ?? null; | |||
There was a problem hiding this comment.
Restore requester device metadata on approval records
This change now records only requestedByConnId for new approvals, but sanitizeSystemRunParamsForForwarding in src/gateway/node-invoke-system-run-approval.ts first binds replay checks to requestedByDeviceId and falls back to strict connId matching when device metadata is absent (lines 192-207). Because per-call clients open different WebSocket connections, legitimate same-device flows now hit APPROVAL_CLIENT_MISMATCH (approval id not valid for this client) instead of passing device-bound validation, which regresses the intended behavior exercised by the existing same-device/different-connection path in src/gateway/server.node-invoke-approval-bypass.test.ts.
Useful? React with 👍 / 👎.
| snapshot?.requestedByConnId != null && | ||
| client?.connId != null && | ||
| client.connId === snapshot.requestedByConnId; |
There was a problem hiding this comment.
Bind self-approval checks to stable requester identity
The new guard blocks only when resolver and requester share the same connId, but connId is per-WebSocket and regenerated on each connection (src/gateway/server/ws-connection.ts:122), while the default gateway call path creates a fresh client connection per request (src/gateway/call.ts:819-872). A compromised requester can therefore submit exec.approval.request and then immediately resolve it from a second connection with allow-once, bypassing this protection and preserving the same self-approval risk the patch is meant to close.
Useful? React with 👍 / 👎.
|
Closing this PR — upstream has refactored However, the self-approval vulnerability is still present in the new architecture. The Will resubmit a minimal fix targeting the current architecture in a new PR. |
Add a self-approval guard inside the validateDecision callback: the same WebSocket connection (connId) that requested an exec approval must not be permitted to approve it. Self-deny is still allowed so users can cancel their own requests. The check fires only when both the requester's connId and the resolver's connId are known and equal, ensuring backward compatibility when client tracking is unavailable. Includes 5 focused test cases covering: - Same connId allow-once → rejected - Different connId → allowed - Self-deny → allowed - Null client → allowed - Same connId allow-always → rejected Supersedes openclaw#48149 which targeted the pre-refactor architecture.
Summary
A prompt-injected or compromised agent can call
exec.approval.requestto register a dangerous shell command and — on the very same WebSocket connection — immediately callexec.approval.resolvewith"allow-once"to execute it, bypassing human-in-the-loop oversight entirely.Vulnerability Details
Affected file:
src/gateway/server-methods/exec-approval.tsHandler:
exec.approval.resolveCVSS v3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N — 8.5 (High)
Root Cause
ExecApprovalRecordalready capturesrequestedByConnIdin theexec.approval.requesthandler:However, the
exec.approval.resolvehandler never reads this field. Despite the aspirational comment "Used to prevent other clients from replaying an approval id", no enforcement existed.Attack Scenario
conn-Aexec.approval.request→ record stored withrequestedByConnId = "conn-A"exec.approval.resolve { decision: "allow-once" }manager.resolve()executes, command runs with no human reviewINVALID_REQUEST "requester cannot approve their own exec request"— rejectedWhy This Matters
The exec approval system is the primary human-oversight gate for shell command execution. If an agent can self-approve, the entire approval workflow provides no security against prompt injection attacks that attempt lateral movement, data exfiltration, or persistent access.
Fix
Insert a connId identity check in
exec.approval.resolveimmediately aftermanager.getSnapshot():The check is intentionally conservative: it only blocks the case where both sides share the same connId. It does not block resolution by other connections, which preserves all legitimate approval flows (human operator UI, trusted supervisor agent on a separate connection, etc.).
Testing
snapshot?.requestedByConnId != nullguard: if a request was created by an anonymous/pre-auth connection withconnId = null, no self-approval blocking occurs (conservative, not restrictive)client?.connId != nullguard: same conservative treatment for the resolverRelated PRs