Skip to content

[Bug]: Cursor escape namespace rewrites opaque ocxc1e_ call IDs #2871

Closed

Description

@Ingwannu

Summary

Current dev rewrites a valid opaque Cursor call ID when it happens to look like output from the new reserved-prefix escape namespace introduced by #2868.

Current-dev reproduction

Current dev: 8d1dc1f
Introduced on dev by: 93b5507 (#2868)

decodeCursorCallId("ocxc1e_b2N4YzFf") === "ocxc1_"

The input contains no CR or LF and is a valid opaque upstream ID. Its payload decodes to the reserved text ocxc1_, so decodeCursorCallId treats it as locally emitted escape data and changes the ID. That can break tool-call/result pairing in request-builder and protobuf continuation paths.

The current test covers ocxc1e_Y2FsbF8x, whose payload decodes to non-reserved call_1, but not the marker-shaped payload above. The focused codec suite therefore stays green while this identity loss remains.

Root boundary

A second public stateless prefix does not remove the ambiguity: arbitrary upstream text can equal any value the encoder emits. The decoder needs provenance proving that this process/session emitted the escape value, or another design that does not guess from attacker- or provider-controlled opaque text alone.

Acceptance criteria

  • The exact opaque input ocxc1e_b2N4YzFf remains byte-for-byte unchanged without local provenance.
  • A freshly escaped ocxc1_- or ocxc1e_-prefixed ID still round-trips in the live tool-result continuation.
  • Missing-provenance and process-restart behavior is explicit and covered; it must not silently rewrite an opaque ID.
  • Request-builder and protobuf-request coverage proves call/result identity, not only the codec helper.
  • The fix remains bounded in memory/lifetime if it uses a session-scoped mapping.

This is a follow-up to the post-merge exact-head review on #2868; #2864/#2868 should remain historical rather than being reopened.

Activity

  1. lidge-jun commented on Aug 29, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 75 / 80

    지금 dev HEAD는 8d1dc1f5d이고, 마지막 머지는 #2869(CentOS 7 / systemd 219 서비스 정리)입니다. 그 바로 아래가 #2868(93b550787, reserved-prefix call-id 코덱)입니다. 패키지 버전은 2.36.0입니다. 이 이슈는 types.ts/config.ts 분할과 겹치지 않습니다. Cursor 어댑터의 call-id 코덱만 봅니다. 본문이 말한 대로 #2864와 #2868은 이미 끝난 기록으로 두고, 이 이슈에서 새 수정을 받으면 됩니다.

    #2868은 예전 코덱의 추측을 없애려고 접두사를 둘로 나눴습니다. 예전에는 ocxc1_ 하나만 썼습니다. 그래서 ocxc1_b2N4YzFf처럼 생긴 업스트림 id를 디코더가 ocxc1_로 풀어 버렸고, 도구 호출과 결과가 짝이 안 맞았습니다. 그래서 지금 src/adapters/cursor/call-id.ts는 줄바꿈이 있는 id는 ocxc1_ 아래에 두고, 이미 예약된 id를 숨길 때는 ocxc1e_ 아래에 둡니다. encodeCursorCallId와 decodeCursorCallId가 그 일을 합니다.

    문제는 두 번째 공개 접두사도 출처를 모른다는 점입니다. Cursor가 주는 id는 아무 글자나 될 수 있고, 그 글자가 우리가 방금 만든 탈출 값과 똑같이 생겼을 수 있습니다. 이 이슈가 준 입력은 ocxc1e_b2N4YzFf입니다. 줄바꿈이 없고, 그대로 써도 되는 불투명 id입니다. 그런데 페이로드 b2N4YzFf를 풀면 ocxc1_가 나옵니다. 디코더는 탈출 네임스페이스이고 풀린 값이 예약어니까 우리가 만든 것이라고 보고 ocxc1_를 돌려줍니다. 지금 체크아웃에서 직접 돌려 봤습니다. decodeCursorCallId("ocxc1e_b2N4YzFf")의 결과는 "ocxc1_"입니다. 같은 함수로 ocxc1_를 다시 인코딩하면 정확히 ocxc1e_b2N4YzFf가 됩니다. 우리가 만든 값과 남의 값이 한 문자열입니다. 접두사를 하나 더 만든 것만으로는 구분이 안 됩니다.

    이 값이 어디로 가는지 보면 왜 큰일입니다. src/adapters/cursor/message-mapper.ts는 도구 호출을 받을 때 encodeCursorCallId로 id를 바꿉니다. 결과를 다시 Cursor에 보낼 때는 src/adapters/cursor/request-builder.ts의 toolResultToText와 src/adapters/cursor/protobuf-request.ts의 toolResultToText / toolCallStep이 decodeCursorCallId를 칩니다. 여기서 id가 바뀌면 호출과 결과가 짝이 안 맞습니다. 사용자는 도구가 멈춘 것처럼 봅니다. 요청 빌더와 프로토 경로 모두 같은 헬퍼를 쓰므로, 코덱 한 곳이 틀리면 두 경로가 같이 틀립니다.

    지금 테스트는 이 구멍을 못 봅니다. tests/cursor-call-id.test.ts는 ocxc1e_Y2FsbF8x를 검사합니다. 그 페이로드는 call_1이라 예약어가 아니라서 그대로 남습니다. 이슈가 말한 ocxc1e_b2N4YzFf는 없습니다. 그래서 코덱 스위트는 초록인데, 실제 짝짓기는 깨질 수 있습니다. request-builder와 protobuf-request에도 이 짝을 직접 확인하는 테스트가 없습니다. 헬퍼만 고치고 통과하면, 이 이슈의 수락 기준을 다 채우지 못합니다.

    라인 52-65 decodeCursorCallId - 탈출 접두사고 풀린 값이 예약어면 무조건 풉니다. ocxc1e_b2N4YzFf가 ocxc1_로 바뀝니다. 출처 확인이 없습니다.
    라인 38-43 encodeCursorCallId - 예약 id를 공개 접두사 아래 base64url로 만듭니다. 디코더가 나중에 이 값을 우리가 만들었는지 알 방법이 없습니다.
    라인 64 escaped ? !isReserved(decoded) : !needsEncoding(decoded) - 탈출 네임스페이스 가드가 예약 문자열이면 통과시킵니다. #2868이 ocxc1_ 쪽에 넣은 가드와 같은 모양의 구멍이 ocxc1e_ 쪽에 남았습니다.
    경로 tests/cursor-call-id.test.ts 라인 52 - ocxc1e_Y2FsbF8x만 검사합니다. 마커 모양 페이로드 ocxc1e_b2N4YzFf는 없습니다.
    경로 src/adapters/cursor/request-builder.ts toolResultToText - decode 결과가 바로 call_id: 텍스트로 나갑니다. 짝이 여기서 깨집니다.
    경로 src/adapters/cursor/protobuf-request.ts toolResultToText / toolCallStep - 같은 decode가 continuation 블롭의 toolCallId에도 들어갑니다.
    경로 #2864 / #2868 - 이미 머지된 기록입니다. 다시 열지 마세요. 이 이슈에서 후속을 받으면 됩니다.

    메인테이너의 판단이 필요한 지점

    • 세션 스코프 맵으로 출처를 남길지, 공개 접두사를 더 늘리지 않는 다른 설계를 쓸지
    • 프로세스 재시작 뒤에 출처가 없으면 불투명 id를 그대로 둘지, 짝을 잃는 쪽을 택할지. 이 이슈는 조용히 바꾸지 말라고 했습니다
    • 맵을 쓴다면 항목 수와 수명을 어디에 묶을지. 이 이슈는 메모리와 수명을 제한하라고 했습니다
    • 이번을 독립 버그 PR로 바로 dev에 받을지

    너의 추천
    이슈를 연 채로 두고, dev에 맞춘 독립 버그 PR을 받으세요. #2864와 #2868은 다시 열지 마세요. 접두사를 하나 더 만드는 것만으로는 부족합니다. 이 프로세스가 만든 탈출 값이라는 출처가 있어야 하고, 출처가 없으면 ocxc1e_b2N4YzFf를 한 글자도 바꾸지 말아야 합니다. 고친 뒤에는 코덱 헬퍼만이 아니라 request-builder와 protobuf-request에서 호출/결과 id가 같은지 증명하세요. types/config 분할과 무관하니 close-don't-rebase 대상이 아닙니다.

    이 댓글은 grok-bot이 작성했습니다

  2. lidge-jun commented on Aug 29, 2026

    @lidge-jun
    Owner

    Fixed on dev in #2876 (318dbd84b). Closing manually since PRs here target dev rather than the default branch, so the Closes link does not auto-close.

    Both reported inputs now pass through byte-for-byte:

    decodeCursorCallId("ocxc1e_b2N4YzFf")   === "ocxc1e_b2N4YzFf"
    decodeCursorCallId("ocxc1e_b2N4YzFlXw") === "ocxc1e_b2N4YzFlXw"
    

    Your framing of the root boundary is what determined the fix. A third prefix would have moved the ambiguity rather than removed it, because opaque upstream text can imitate any value a stateless encoder emits — so the decoder now requires provenance instead of inferring intent from shape.

    Encoded ids carry an HMAC-SHA256 tag over the prefix and payload, keyed by a 32-byte value generated per process, with domain separation and a constant-time compare. Only an id whose tag verifies is decoded; anything unsigned, malformed, or merely prefix-shaped is returned untouched. That keeps provenance state constant-size, which is your criterion 5 — there is no id map to bound because nothing is remembered.

    On criterion 3, the process key is deliberately ephemeral: after a restart, an id encoded by the previous process no longer verifies and is therefore left unchanged rather than guessed at. That is the fail-safe direction — a composite id from before the restart reaches Cursor as-is instead of being rewritten. The tradeoff is documented at the top of the codec so it is not mistaken for an oversight.

    Coverage extends past the codec helper into tests/cursor-request-builder.test.ts and tests/cursor-blob.test.ts for call/result identity, per criterion 4. 287 tests pass across the eight files that touch call ids, and two mutations were driven red: restoring the #2868 stateless decoder fails 5, and rejecting all authenticated provenance fails 10.

    Not verified: no live Cursor traffic. As you asked, #2864 and #2868 stay historical rather than reopened.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions