Skip to content

Commit cd5bf22

Browse files
committed
fix(_utils): scope is_caller annotation to caller's socket
Before: `_serialize_pane`, `snapshot_pane`, and `search_panes` compared `pane.pane_id == TMUX_PANE` without consulting the tmux socket. A caller on socket A pane `%0` therefore saw `is_caller=True` for any `%0` on any other server — a false positive that polluted every read-path tool surface returning `PaneInfo` / `PaneSnapshot` / `PaneContentMatch`. The 0.1.0a2 caller-identity-scoping work already introduced `_caller_is_on_server` (realpath + macOS TMUX_TMPDIR basename fallback), but wired it into the self-kill guard only — the informational annotation kept its bare-equality implementation. This change centralizes the annotation via `_compute_is_caller(pane)` in `_utils.py`, which reuses `_caller_is_on_server(pane.server, caller)`. All three callers migrate; the now-dead `_get_caller_pane_id` helper is removed along with its two unit tests. Regression test `test_serialize_pane_is_caller_false_across_sockets` spawns two libtmux servers, points the caller at server A, and asserts a pane on server B with the same pane_id is flagged `False`. Closes #19
1 parent 8e2e0c3 commit cd5bf22

7 files changed

Lines changed: 200 additions & 41 deletions

File tree

CHANGES

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ _Notes on upcoming releases will be added here_
1616
conftest reaper is needed in libtmux-mcp — the upstream fixtures
1717
self-clean.
1818

19+
### Fixes
20+
21+
- Pane `is_caller` annotation no longer false-positives across tmux
22+
sockets. `_serialize_pane`, `snapshot_pane`, and `search_panes` all
23+
compared `pane.pane_id == TMUX_PANE` without verifying the caller's
24+
socket, so a caller on socket A pane `%0` was marked `is_caller=True`
25+
for any `%0` on any other server. The annotation now reuses
26+
`_caller_is_on_server` (the same socket-scoped comparator used by
27+
the self-kill guard) via the new `_compute_is_caller` helper
28+
(#19).
29+
1930
## libtmux-mcp 0.1.0a2 (2026-04-19)
2031

2132
_FastMCP alignment: new tools, prompts, and middleware (#15)_

src/libtmux_mcp/_utils.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,42 @@ def _get_caller_identity() -> CallerIdentity | None:
8787
)
8888

8989

90-
def _get_caller_pane_id() -> str | None:
91-
"""Return the TMUX_PANE of the calling process, or None if not in tmux.
92-
93-
Thin wrapper around :func:`_get_caller_identity` kept for callers that
94-
only need the pane id (notably :func:`_serialize_pane`).
90+
def _compute_is_caller(pane: Pane) -> bool | None:
91+
"""Decide whether ``pane`` is the MCP caller's own tmux pane.
92+
93+
The returned value is used as the ``is_caller`` annotation on
94+
:class:`~libtmux_mcp.models.PaneInfo`,
95+
:class:`~libtmux_mcp.models.PaneSnapshot`, and
96+
:class:`~libtmux_mcp.models.PaneContentMatch`.
97+
98+
Tri-state semantics match the original bare-equality check:
99+
100+
* ``None`` — process is not inside tmux at all (neither ``TMUX`` nor
101+
``TMUX_PANE`` are set). No caller exists, so the annotation
102+
carries no signal.
103+
* ``True`` — the caller's ``TMUX_PANE`` matches ``pane.pane_id``
104+
*and* :func:`_caller_is_strictly_on_server` confirms the
105+
caller's socket realpath equals the target's.
106+
* ``False`` — the pane ids differ, or they match but the socket
107+
does not (or cannot be proven to). A bare pane-id equality
108+
check would have returned ``True`` here, which is the
109+
cross-socket false-positive fixed by
110+
tmux-python/libtmux-mcp#19.
111+
112+
Uses :func:`_caller_is_strictly_on_server` rather than
113+
:func:`_caller_is_on_server`: the kill-guard comparator is
114+
conservative-True-when-uncertain (right for blocking destructive
115+
actions, wrong for an informational annotation that should
116+
demand a positive match). The strict variant declines the
117+
basename fallback, the unresolvable-target branch, and the
118+
socket-path-unset branch so ambiguous cases resolve to ``False``.
95119
"""
96120
caller = _get_caller_identity()
97-
return caller.pane_id if caller else None
121+
if caller is None or caller.pane_id is None:
122+
return None
123+
return caller.pane_id == pane.pane_id and _caller_is_strictly_on_server(
124+
pane.server, caller
125+
)
98126

99127

100128
def _effective_socket_path(server: Server) -> str | None:
@@ -201,6 +229,45 @@ def _caller_is_on_server(server: Server, caller: CallerIdentity | None) -> bool:
201229
return caller_basename == target_name
202230

203231

232+
def _caller_is_strictly_on_server(
233+
server: Server, caller: CallerIdentity | None
234+
) -> bool:
235+
"""Return True only on a confirmed socket-path match.
236+
237+
Counterpart to :func:`_caller_is_on_server` for the informational
238+
:attr:`~libtmux_mcp.models.PaneInfo.is_caller` annotation. The
239+
destructive-action guard is biased toward True-when-uncertain so a
240+
macOS ``$TMUX_TMPDIR`` divergence cannot fool it into permitting
241+
self-kill; the annotation cannot absorb that bias — ambiguous cases
242+
are exactly the cross-socket false positives documented by
243+
tmux-python/libtmux-mcp#19. This function therefore declines every
244+
branch other than a confirmed ``realpath`` match.
245+
246+
Decision table:
247+
248+
* ``caller is None`` → ``False``. No caller identity.
249+
* ``caller.socket_path`` unset (``TMUX_PANE`` set without ``TMUX``)
250+
→ ``False``. We cannot verify the caller is on this server.
251+
* target server's effective socket path unresolvable → ``False``.
252+
* ``realpath`` of caller's socket path equals target's effective
253+
path → ``True``. Primary and only positive signal.
254+
* Fallback on ``OSError`` from ``realpath``: exact string match
255+
→ ``True``. Still a positive signal, just without the resolve
256+
step.
257+
* Otherwise → ``False`` (including the basename-only match that
258+
:func:`_caller_is_on_server` permits as a conservative block).
259+
"""
260+
if caller is None or not caller.socket_path:
261+
return False
262+
target = _effective_socket_path(server)
263+
if not target:
264+
return False
265+
try:
266+
return os.path.realpath(caller.socket_path) == os.path.realpath(target)
267+
except OSError:
268+
return caller.socket_path == target
269+
270+
204271
# ---------------------------------------------------------------------------
205272
# Safety tier tags
206273
# ---------------------------------------------------------------------------
@@ -757,7 +824,6 @@ def _serialize_pane(pane: Pane) -> PaneInfo:
757824
from libtmux_mcp.models import PaneInfo
758825

759826
assert pane.pane_id is not None
760-
caller_pane_id = _get_caller_pane_id()
761827
return PaneInfo(
762828
pane_id=pane.pane_id,
763829
pane_index=getattr(pane, "pane_index", None),
@@ -770,7 +836,7 @@ def _serialize_pane(pane: Pane) -> PaneInfo:
770836
pane_active=getattr(pane, "pane_active", None),
771837
window_id=pane.window_id,
772838
session_id=pane.session_id,
773-
is_caller=pane.pane_id == caller_pane_id if caller_pane_id else None,
839+
is_caller=_compute_is_caller(pane),
774840
)
775841

776842

src/libtmux_mcp/models.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,13 @@ class PaneInfo(BaseModel):
6969
is_caller: bool | None = Field(
7070
default=None,
7171
description=(
72-
"True if this pane is the MCP caller's own pane "
73-
"(detected via TMUX_PANE env var)"
72+
"MCP caller identity for this pane. ``True`` when the pane "
73+
"matches the caller's ``TMUX_PANE`` *and* lives on the same "
74+
"tmux socket as the caller's ``TMUX`` (verified via socket "
75+
"realpath); ``False`` otherwise, including the case where "
76+
"the pane id matches but the socket does not or cannot be "
77+
"proven to; ``None`` when the MCP process is not running "
78+
"inside tmux at all."
7479
),
7580
)
7681

@@ -93,8 +98,13 @@ class PaneContentMatch(BaseModel):
9398
is_caller: bool | None = Field(
9499
default=None,
95100
description=(
96-
"True if this pane is the MCP caller's own pane "
97-
"(detected via TMUX_PANE env var)"
101+
"MCP caller identity for this pane. ``True`` when the pane "
102+
"matches the caller's ``TMUX_PANE`` *and* lives on the same "
103+
"tmux socket as the caller's ``TMUX`` (verified via socket "
104+
"realpath); ``False`` otherwise, including the case where "
105+
"the pane id matches but the socket does not or cannot be "
106+
"proven to; ``None`` when the MCP process is not running "
107+
"inside tmux at all."
98108
),
99109
)
100110

@@ -178,7 +188,15 @@ class PaneSnapshot(BaseModel):
178188
)
179189
is_caller: bool | None = Field(
180190
default=None,
181-
description="True if this is the MCP caller's own pane",
191+
description=(
192+
"MCP caller identity for this pane. ``True`` when the pane "
193+
"matches the caller's ``TMUX_PANE`` *and* lives on the same "
194+
"tmux socket as the caller's ``TMUX`` (verified via socket "
195+
"realpath); ``False`` otherwise, including the case where "
196+
"the pane id matches but the socket does not or cannot be "
197+
"proven to; ``None`` when the MCP process is not running "
198+
"inside tmux at all."
199+
),
182200
)
183201
content_truncated: bool = Field(
184202
default=False,

src/libtmux_mcp/tools/pane_tools/meta.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from libtmux_mcp._utils import (
6-
_get_caller_pane_id,
6+
_compute_is_caller,
77
_get_server,
88
_resolve_pane,
99
handle_tool_errors,
@@ -160,7 +160,6 @@ def snapshot_pane(
160160
pane_mode_raw = parts[5]
161161
scroll_raw = parts[6]
162162

163-
caller_pane_id = _get_caller_pane_id()
164163
return PaneSnapshot(
165164
pane_id=pane.pane_id or "",
166165
content=content,
@@ -175,7 +174,7 @@ def snapshot_pane(
175174
title=parts[8] if parts[8] else None,
176175
pane_current_command=parts[9] if parts[9] else None,
177176
pane_current_path=parts[10] if parts[10] else None,
178-
is_caller=(pane.pane_id == caller_pane_id if caller_pane_id else None),
177+
is_caller=_compute_is_caller(pane),
179178
content_truncated=truncated,
180179
content_truncated_lines=dropped,
181180
)

src/libtmux_mcp/tools/pane_tools/search.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastmcp.exceptions import ToolError
88

99
from libtmux_mcp._utils import (
10-
_get_caller_pane_id,
10+
_compute_is_caller,
1111
_get_server,
1212
_resolve_session,
1313
handle_tool_errors,
@@ -221,7 +221,6 @@ def search_panes(
221221
# sort the matching panes by pane_id for deterministic ordering,
222222
# then slice by offset / limit. Per-pane matched_lines is
223223
# tail-truncated to keep the most recent matches.
224-
caller_pane_id = _get_caller_pane_id()
225224
all_matches: list[PaneContentMatch] = []
226225
per_pane_truncated = False
227226
for pane_id_str in matching_pane_ids:
@@ -251,7 +250,7 @@ def search_panes(
251250
session_id=pane.session_id,
252251
session_name=getattr(session_obj, "session_name", None),
253252
matched_lines=matched_lines,
254-
is_caller=(pane_id_str == caller_pane_id if caller_pane_id else None),
253+
is_caller=_compute_is_caller(pane),
255254
)
256255
)
257256

tests/test_pane_tools.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -783,10 +783,14 @@ class SearchPanesCallerFixture(t.NamedTuple):
783783

784784
SEARCH_PANES_CALLER_FIXTURES: list[SearchPanesCallerFixture] = [
785785
SearchPanesCallerFixture(
786-
test_id="caller_pane_annotated",
786+
# TMUX_PANE without TMUX: the strict comparator cannot verify the
787+
# caller's socket and returns ``False`` rather than conservatively
788+
# assuming same-server. Full-TMUX-env coverage lives in
789+
# ``tests/test_utils.py::test_serialize_pane_is_caller_false_across_sockets``.
790+
test_id="caller_pane_no_tmux_env",
787791
tmux_pane_env=None,
788792
use_real_pane_id=True,
789-
expected_is_caller=True,
793+
expected_is_caller=False,
790794
),
791795
SearchPanesCallerFixture(
792796
test_id="outside_tmux_no_annotation",

tests/test_utils.py

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
TAG_READONLY,
2121
VALID_SAFETY_LEVELS,
2222
_apply_filters,
23-
_get_caller_pane_id,
2423
_get_server,
2524
_invalidate_server,
2625
_resolve_pane,
@@ -306,23 +305,6 @@ def test_apply_filters(
306305
assert len(result) >= 1
307306

308307

309-
# ---------------------------------------------------------------------------
310-
# _get_caller_pane_id / _serialize_pane is_caller tests
311-
# ---------------------------------------------------------------------------
312-
313-
314-
def test_get_caller_pane_id_returns_env(monkeypatch: pytest.MonkeyPatch) -> None:
315-
"""_get_caller_pane_id returns TMUX_PANE when set."""
316-
monkeypatch.setenv("TMUX_PANE", "%42")
317-
assert _get_caller_pane_id() == "%42"
318-
319-
320-
def test_get_caller_pane_id_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
321-
"""_get_caller_pane_id returns None outside tmux."""
322-
monkeypatch.delenv("TMUX_PANE", raising=False)
323-
assert _get_caller_pane_id() is None
324-
325-
326308
# ---------------------------------------------------------------------------
327309
# Caller identity parsing tests
328310
# ---------------------------------------------------------------------------
@@ -532,10 +514,16 @@ class SerializePaneCallerFixture(t.NamedTuple):
532514

533515
SERIALIZE_PANE_CALLER_FIXTURES: list[SerializePaneCallerFixture] = [
534516
SerializePaneCallerFixture(
535-
test_id="matching_pane_id",
517+
# TMUX_PANE is set to the real pane id but TMUX is unset, so the
518+
# caller's socket cannot be verified. The strict comparator
519+
# declines to assume same-server: ``False`` not ``True``.
520+
# Pre-fixup this returned ``True`` via ``_caller_is_on_server``'s
521+
# conservative-True branch — a cross-socket false positive the
522+
# informational annotation must not carry.
523+
test_id="matching_pane_id_no_tmux_env",
536524
tmux_pane_env=None,
537525
use_real_pane_id=True,
538-
expected_is_caller=True,
526+
expected_is_caller=False,
539527
),
540528
SerializePaneCallerFixture(
541529
test_id="non_matching_pane_id",
@@ -577,6 +565,80 @@ def test_serialize_pane_is_caller(
577565
assert data.is_caller is expected_is_caller
578566

579567

568+
def test_serialize_pane_is_caller_false_across_sockets(
569+
TestServer: type[Server],
570+
monkeypatch: pytest.MonkeyPatch,
571+
) -> None:
572+
"""is_caller must not flag a pane on a *different* tmux socket.
573+
574+
Regression for tmux-python/libtmux-mcp#19. Before the fix,
575+
``_serialize_pane`` compared ``pane.pane_id == TMUX_PANE`` without
576+
any socket check — so a caller inside pane ``%0`` on socket A saw
577+
``is_caller=True`` for any pane with id ``%0`` on any other server.
578+
579+
Two fresh libtmux servers emit matching pane ids (both start at
580+
``%0``), so this reproduces the false-positive exactly. Point the
581+
caller at server A, serialize pane ``%0`` on server B, assert the
582+
annotation says ``False``.
583+
"""
584+
from libtmux_mcp._utils import _effective_socket_path
585+
586+
server_a = TestServer()
587+
session_a = server_a.new_session(session_name="mcp_issue19_a")
588+
pane_a = session_a.active_window.active_pane
589+
assert pane_a is not None and pane_a.pane_id is not None
590+
591+
server_b = TestServer()
592+
session_b = server_b.new_session(session_name="mcp_issue19_b")
593+
pane_b = session_b.active_window.active_pane
594+
assert pane_b is not None and pane_b.pane_id is not None
595+
596+
# Prerequisite: the two freshly-spawned servers emitted matching
597+
# pane ids. If they didn't (a tmux version quirk), the false
598+
# positive can't be exercised — skip rather than fail.
599+
if pane_a.pane_id != pane_b.pane_id:
600+
pytest.skip(
601+
f"sibling servers emitted distinct pane ids "
602+
f"({pane_a.pane_id} vs {pane_b.pane_id}); cannot reproduce issue #19"
603+
)
604+
605+
socket_a = _effective_socket_path(server_a)
606+
assert socket_a is not None
607+
monkeypatch.setenv("TMUX", f"{socket_a},1,{session_a.session_id or '$0'}")
608+
monkeypatch.setenv("TMUX_PANE", pane_a.pane_id)
609+
610+
# Pane on the *other* server — must be flagged False even though
611+
# its pane_id matches TMUX_PANE.
612+
assert _serialize_pane(pane_b).is_caller is False
613+
# Sanity: on the caller's own server, same pane_id *is* the caller.
614+
assert _serialize_pane(pane_a).is_caller is True
615+
616+
617+
def test_serialize_pane_is_caller_requires_tmux_env_not_just_pane(
618+
mcp_pane: Pane,
619+
monkeypatch: pytest.MonkeyPatch,
620+
) -> None:
621+
"""``TMUX_PANE`` alone must not declare a caller identity.
622+
623+
Regression for the subtle cross-socket false positive that
624+
:func:`_caller_is_on_server`'s "socket_path unset → conservative
625+
True" branch would otherwise introduce. When the MCP process has
626+
``TMUX_PANE`` in its environment but not ``TMUX`` — an unusual but
627+
possible state an agent harness can produce — the caller's socket
628+
is unknowable. The strict comparator declines to assert
629+
``is_caller=True`` in that case so any pane whose id happens to
630+
match ``TMUX_PANE`` across *any* server is annotated ``False``,
631+
not a false positive. Exercises the code path that was left
632+
un-covered after the direct ``_get_caller_pane_id`` unit tests
633+
were removed.
634+
"""
635+
assert mcp_pane.pane_id is not None
636+
monkeypatch.setenv("TMUX_PANE", mcp_pane.pane_id)
637+
monkeypatch.delenv("TMUX", raising=False)
638+
639+
assert _serialize_pane(mcp_pane).is_caller is False
640+
641+
580642
# ---------------------------------------------------------------------------
581643
# Annotation and tag constants tests
582644
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)