fix: bind Claude permission replies to session#56420
Conversation
Greptile SummaryThis PR closes a security hole where any session could spoof a Claude permission reply (
Confidence Score: 5/5Safe to merge — the security fix is correctly implemented and well-tested; remaining findings are minor defensive suggestions that do not affect normal operation. All open findings are P2 style/defensive suggestions (empty-string schema validation, missing .trim() normalization). Neither constitutes a present defect for any realistic caller. The core session-binding logic is sound, the early-return path prevents double-processing, and the test suite covers both the attack vector and the happy path. No files require special attention beyond the two minor suggestions in src/mcp/channel-shared.ts and src/mcp/channel-server.ts.
|
| Filename | Overview |
|---|---|
| src/mcp/channel-bridge.ts | Stores sessionKey with each pending permission and validates it on reply. The core session-binding logic is correct: attacker sessions that match the reply pattern fall through to normal message enqueueing without triggering the permission callback. |
| src/mcp/channel-shared.ts | Added sessionKey to ClaudePermissionRequest type and session_key to ClaudePermissionRequestSchema. Minor defensive gap: session_key uses z.string() without .min(1), permitting empty strings that create irresolvable pending permissions. |
| src/mcp/channel-server.ts | Correctly propagates session_key from the notification schema through to handleClaudePermissionRequest. The raw (untrimmed) string is passed as sessionKey, which could mismatch trimmed values at comparison time. |
| src/mcp/channel-server.test.ts | New unit test exercises attacker-session rejection and trusted-session acceptance. Integration test updated to include session_key in the permission_request notification and adds an attacker-session negative assertion. Coverage is thorough for the new security path. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/mcp/channel-shared.ts
Line: 122
Comment:
**`session_key` accepts empty strings**
`z.string()` allows an empty `""` to pass schema validation. If a caller sends an empty `session_key`, the pending permission is stored with an empty string as `sessionKey`. Because `toText(payload.sessionKey)` in `handleSessionMessageEvent` returns `undefined` for empty/blank strings, the comparison `pendingPermission?.sessionKey === sessionKey` would evaluate to false for such a request. The permission can never be resolved and leaks in `pendingClaudePermissions` indefinitely.
Consider tightening the schema with `.min(1)` to reject empty strings at the boundary.
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/mcp/channel-server.ts
Line: 43
Comment:
**`sessionKey` not normalized before storage**
`params.session_key` is stored as-is (raw, not trimmed). In contrast, `handleSessionMessageEvent` extracts the inbound session key via `toText(payload.sessionKey)`, which trims leading/trailing whitespace. If a notification ever carries a `session_key` with surrounding whitespace, the stored value will never equal the trimmed inbound key, making the bound permission permanently irresolvable without any error.
Applying `.trim()` here would make this consistent with the downstream comparison.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "MCP: bind Claude permission replies to s..." | Re-trigger Greptile
| method: z.literal("notifications/claude/channel/permission_request"), | ||
| params: z.object({ | ||
| request_id: z.string(), | ||
| session_key: z.string(), |
There was a problem hiding this comment.
session_key accepts empty strings
z.string() allows an empty "" to pass schema validation. If a caller sends an empty session_key, the pending permission is stored with an empty string as sessionKey. Because toText(payload.sessionKey) in handleSessionMessageEvent returns undefined for empty/blank strings, the comparison pendingPermission?.sessionKey === sessionKey would evaluate to false for such a request. The permission can never be resolved and leaks in pendingClaudePermissions indefinitely.
Consider tightening the schema with .min(1) to reject empty strings at the boundary.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcp/channel-shared.ts
Line: 122
Comment:
**`session_key` accepts empty strings**
`z.string()` allows an empty `""` to pass schema validation. If a caller sends an empty `session_key`, the pending permission is stored with an empty string as `sessionKey`. Because `toText(payload.sessionKey)` in `handleSessionMessageEvent` returns `undefined` for empty/blank strings, the comparison `pendingPermission?.sessionKey === sessionKey` would evaluate to false for such a request. The permission can never be resolved and leaks in `pendingClaudePermissions` indefinitely.
Consider tightening the schema with `.min(1)` to reject empty strings at the boundary.
How can I resolve this? If you propose a fix, please make it concise.| @@ -42,6 +42,7 @@ export async function createOpenClawChannelMcpServer(opts: OpenClawMcpServeOptio | |||
| server.server.setNotificationHandler(ClaudePermissionRequestSchema, async ({ params }) => { | |||
| await bridge.handleClaudePermissionRequest({ | |||
There was a problem hiding this comment.
sessionKey not normalized before storage
params.session_key is stored as-is (raw, not trimmed). In contrast, handleSessionMessageEvent extracts the inbound session key via toText(payload.sessionKey), which trims leading/trailing whitespace. If a notification ever carries a session_key with surrounding whitespace, the stored value will never equal the trimmed inbound key, making the bound permission permanently irresolvable without any error.
Applying .trim() here would make this consistent with the downstream comparison.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcp/channel-server.ts
Line: 43
Comment:
**`sessionKey` not normalized before storage**
`params.session_key` is stored as-is (raw, not trimmed). In contrast, `handleSessionMessageEvent` extracts the inbound session key via `toText(payload.sessionKey)`, which trims leading/trailing whitespace. If a notification ever carries a `session_key` with surrounding whitespace, the stored value will never equal the trimmed inbound key, making the bound permission permanently irresolvable without any error.
Applying `.trim()` here would make this consistent with the downstream comparison.
How can I resolve this? If you propose a fix, please make it concise.|
This pull request has been automatically marked as stale due to inactivity. |
|
Thanks for the context here. I swept through the related work, and this is now duplicate or superseded. Keep this PR open because current main still has the cross-session Claude permission approval bug, but the submitted branch is not merge-ready: it conflicts with the merged pending-map TTL cleanup, adds a required MCP notification field without upgrade approval, and lacks real behavior proof. Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Canonical path: Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review. So I’m closing this here because the remaining work is already tracked in the canonical issue. Review detailsBest possible solution: Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review. Do we have a high-confidence way to reproduce the issue? Yes from source inspection: current main stores pending Claude permissions by request id timestamp and accepts Is this the best way to solve the issue? No as submitted. Session binding is the right invariant, but this branch needs a current-main rebase, a maintainer-approved protocol upgrade path, non-empty normalized session keys, and real behavior proof. Security review: Security review needs attention: The patch improves an approval boundary but needs attention for the required protocol change, normalization gap, and missing real behavior proof.
AGENTS.md: found and applied where relevant. What I checked:
Likely related people:
Codex review notes: model internal, reasoning high; reviewed against 12c34fc3a951. |
…clear OpenClawChannelBridge held two instance-bound Maps (pendingClaudePermissions L50, pendingApprovals L51) that lacked TTL/sweeper, close-clear, and cap — while the sibling collections in the same class (queue cap=1000, pendingWaiters close-clear + per-waiter setTimeout) were all bounded. Long-running `openclaw mcp serve` processes accumulated entries on missed Claude permission replies and on gateway WebSocket drops that silenced *.approval.resolved frames. Fix scope (one-thing-per-PR): - Wrap entries in PendingClaudePermissionEntry / PendingApprovalEntry with internal createdAtMs / trackedAtMs (instance bookkeeping, decoupled from payload-supplied fields so sweeper fallback is stable). - Lazy-start a 5-min sweepPendingExpired() interval on first set, .unref()'d so it never keeps the process alive. - TTLs: 1h for pendingClaudePermissions (createdAtMs-anchored), respects payload.expiresAtMs for pendingApprovals (falls back to trackedAtMs+30min when the server omits it). - close() now clears both Maps and clearInterval()s the sweeper. - handleClaudePermissionRequest / trackApproval early-return when this.closed to prevent post-close ghost writes. Cap/FIFO is intentionally split to a follow-up PR (one-thing-per-PR; sweeper covers the slow leak; cap addresses adversarial bursts). Tests: - New src/mcp/channel-bridge.test.ts (7 it blocks): TTL sweep for both Maps, default-TTL fallback, both-payload-fields-undefined case, close clears + vi.getTimerCount()===0, no-op-after-close, lazy-init guard. - channel-server.test.ts:366 fixture synced to wrapper shape. AI-assisted (openclaw-audit pipeline). Closes openclaw#71646. cross-review: - 5-agent post-harness primary_decision=proceed (real=4 + fix-insufficient=1 scope-down → applied: cap deferred). - 3-agent pre-pr round 1: critical-devil flagged 4 issues → fixed in this commit. - 3-agent pre-pr round 2: 3/3 real-problem-real-fix → proceed_to_pr. Upstream-dup check clean: 6w channel-bridge.ts commits all refactor/seam axis; PR openclaw#56420 (sessionKey binding) orthogonal but touches the same set() lines — rebase order may matter.
…clear OpenClawChannelBridge held two instance-bound Maps (pendingClaudePermissions L50, pendingApprovals L51) that lacked TTL/sweeper, close-clear, and cap — while the sibling collections in the same class (queue cap=1000, pendingWaiters close-clear + per-waiter setTimeout) were all bounded. Long-running `openclaw mcp serve` processes accumulated entries on missed Claude permission replies and on gateway WebSocket drops that silenced *.approval.resolved frames. Fix scope (one-thing-per-PR): - Wrap entries in PendingClaudePermissionEntry / PendingApprovalEntry with internal createdAtMs / trackedAtMs (instance bookkeeping, decoupled from payload-supplied fields so sweeper fallback is stable). - Lazy-start a 5-min sweepPendingExpired() interval on first set, .unref()'d so it never keeps the process alive. - TTLs: 1h for pendingClaudePermissions (createdAtMs-anchored), respects payload.expiresAtMs for pendingApprovals (falls back to trackedAtMs+30min when the server omits it). - close() now clears both Maps and clearInterval()s the sweeper. - handleClaudePermissionRequest / trackApproval early-return when this.closed to prevent post-close ghost writes. Cap/FIFO is intentionally split to a follow-up PR (one-thing-per-PR; sweeper covers the slow leak; cap addresses adversarial bursts). Tests: - New src/mcp/channel-bridge.test.ts (7 it blocks): TTL sweep for both Maps, default-TTL fallback, both-payload-fields-undefined case, close clears + vi.getTimerCount()===0, no-op-after-close, lazy-init guard. - channel-server.test.ts:366 fixture synced to wrapper shape. AI-assisted (openclaw-audit pipeline). Closes openclaw#71646. cross-review: - 5-agent post-harness primary_decision=proceed (real=4 + fix-insufficient=1 scope-down → applied: cap deferred). - 3-agent pre-pr round 1: critical-devil flagged 4 issues → fixed in this commit. - 3-agent pre-pr round 2: 3/3 real-problem-real-fix → proceed_to_pr. Upstream-dup check clean: 6w channel-bridge.ts commits all refactor/seam axis; PR openclaw#56420 (sessionKey binding) orthogonal but touches the same set() lines — rebase order may matter.
|
ClawSweeper PR egg 🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat. Where did the egg go?
|
…clear OpenClawChannelBridge held two instance-bound Maps (pendingClaudePermissions L50, pendingApprovals L51) that lacked TTL/sweeper, close-clear, and cap — while the sibling collections in the same class (queue cap=1000, pendingWaiters close-clear + per-waiter setTimeout) were all bounded. Long-running `openclaw mcp serve` processes accumulated entries on missed Claude permission replies and on gateway WebSocket drops that silenced *.approval.resolved frames. Fix scope (one-thing-per-PR): - Wrap entries in PendingClaudePermissionEntry / PendingApprovalEntry with internal createdAtMs / trackedAtMs (instance bookkeeping, decoupled from payload-supplied fields so sweeper fallback is stable). - Lazy-start a 5-min sweepPendingExpired() interval on first set, .unref()'d so it never keeps the process alive. - TTLs: 1h for pendingClaudePermissions (createdAtMs-anchored), respects payload.expiresAtMs for pendingApprovals (falls back to trackedAtMs+30min when the server omits it). - close() now clears both Maps and clearInterval()s the sweeper. - handleClaudePermissionRequest / trackApproval early-return when this.closed to prevent post-close ghost writes. Cap/FIFO is intentionally split to a follow-up PR (one-thing-per-PR; sweeper covers the slow leak; cap addresses adversarial bursts). Tests: - New src/mcp/channel-bridge.test.ts (7 it blocks): TTL sweep for both Maps, default-TTL fallback, both-payload-fields-undefined case, close clears + vi.getTimerCount()===0, no-op-after-close, lazy-init guard. - channel-server.test.ts:366 fixture synced to wrapper shape. AI-assisted (openclaw-audit pipeline). Closes openclaw#71646. cross-review: - 5-agent post-harness primary_decision=proceed (real=4 + fix-insufficient=1 scope-down → applied: cap deferred). - 3-agent pre-pr round 1: critical-devil flagged 4 issues → fixed in this commit. - 3-agent pre-pr round 2: 3/3 real-problem-real-fix → proceed_to_pr. Upstream-dup check clean: 6w channel-bridge.ts commits all refactor/seam axis; PR openclaw#56420 (sessionKey binding) orthogonal but touches the same set() lines — rebase order may matter.
…clear OpenClawChannelBridge held two instance-bound Maps (pendingClaudePermissions L50, pendingApprovals L51) that lacked TTL/sweeper, close-clear, and cap — while the sibling collections in the same class (queue cap=1000, pendingWaiters close-clear + per-waiter setTimeout) were all bounded. Long-running `openclaw mcp serve` processes accumulated entries on missed Claude permission replies and on gateway WebSocket drops that silenced *.approval.resolved frames. Fix scope (one-thing-per-PR): - Wrap entries in PendingClaudePermissionEntry / PendingApprovalEntry with internal createdAtMs / trackedAtMs (instance bookkeeping, decoupled from payload-supplied fields so sweeper fallback is stable). - Lazy-start a 5-min sweepPendingExpired() interval on first set, .unref()'d so it never keeps the process alive. - TTLs: 1h for pendingClaudePermissions (createdAtMs-anchored), respects payload.expiresAtMs for pendingApprovals (falls back to trackedAtMs+30min when the server omits it). - close() now clears both Maps and clearInterval()s the sweeper. - handleClaudePermissionRequest / trackApproval early-return when this.closed to prevent post-close ghost writes. Cap/FIFO is intentionally split to a follow-up PR (one-thing-per-PR; sweeper covers the slow leak; cap addresses adversarial bursts). Tests: - New src/mcp/channel-bridge.test.ts (7 it blocks): TTL sweep for both Maps, default-TTL fallback, both-payload-fields-undefined case, close clears + vi.getTimerCount()===0, no-op-after-close, lazy-init guard. - channel-server.test.ts:366 fixture synced to wrapper shape. AI-assisted (openclaw-audit pipeline). Closes openclaw#71646. cross-review: - 5-agent post-harness primary_decision=proceed (real=4 + fix-insufficient=1 scope-down → applied: cap deferred). - 3-agent pre-pr round 1: critical-devil flagged 4 issues → fixed in this commit. - 3-agent pre-pr round 2: 3/3 real-problem-real-fix → proceed_to_pr. Upstream-dup check clean: 6w channel-bridge.ts commits all refactor/seam axis; PR openclaw#56420 (sessionKey binding) orthogonal but touches the same set() lines — rebase order may matter.
…clear OpenClawChannelBridge held two instance-bound Maps (pendingClaudePermissions L50, pendingApprovals L51) that lacked TTL/sweeper, close-clear, and cap — while the sibling collections in the same class (queue cap=1000, pendingWaiters close-clear + per-waiter setTimeout) were all bounded. Long-running `openclaw mcp serve` processes accumulated entries on missed Claude permission replies and on gateway WebSocket drops that silenced *.approval.resolved frames. Fix scope (one-thing-per-PR): - Wrap entries in PendingClaudePermissionEntry / PendingApprovalEntry with internal createdAtMs / trackedAtMs (instance bookkeeping, decoupled from payload-supplied fields so sweeper fallback is stable). - Lazy-start a 5-min sweepPendingExpired() interval on first set, .unref()'d so it never keeps the process alive. - TTLs: 1h for pendingClaudePermissions (createdAtMs-anchored), respects payload.expiresAtMs for pendingApprovals (falls back to trackedAtMs+30min when the server omits it). - close() now clears both Maps and clearInterval()s the sweeper. - handleClaudePermissionRequest / trackApproval early-return when this.closed to prevent post-close ghost writes. Cap/FIFO is intentionally split to a follow-up PR (one-thing-per-PR; sweeper covers the slow leak; cap addresses adversarial bursts). Tests: - New src/mcp/channel-bridge.test.ts (7 it blocks): TTL sweep for both Maps, default-TTL fallback, both-payload-fields-undefined case, close clears + vi.getTimerCount()===0, no-op-after-close, lazy-init guard. - channel-server.test.ts:366 fixture synced to wrapper shape. AI-assisted (openclaw-audit pipeline). Closes openclaw#71646. cross-review: - 5-agent post-harness primary_decision=proceed (real=4 + fix-insufficient=1 scope-down → applied: cap deferred). - 3-agent pre-pr round 1: critical-devil flagged 4 issues → fixed in this commit. - 3-agent pre-pr round 2: 3/3 real-problem-real-fix → proceed_to_pr. Upstream-dup check clean: 6w channel-bridge.ts commits all refactor/seam axis; PR openclaw#56420 (sessionKey binding) orthogonal but touches the same set() lines — rebase order may matter.
|
This pull request has been automatically marked as stale due to inactivity. |
|
ClawSweeper applied the proposed close for this PR.
|
Motivation
yes/no <id>) could be spoofed from any session because pending requests were keyed only by a shortrequest_id.Description
session_keyin thenotifications/claude/channel/permission_requestschema and propagate it through the MCP server; changedsrc/mcp/channel-shared.tsandsrc/mcp/channel-server.ts.sessionKeyand only acceptyes/no <request_id>replies when the inbound message'ssessionKeymatches; changedsrc/mcp/channel-bridge.ts.src/mcp/channel-server.test.ts.Testing
pnpm test -- src/mcp/channel-server.test.ts, which passed (1 passed, 6 tests).pnpm check, which completed successfully (lint/type checks passed).