-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuffer_tools.py
More file actions
407 lines (356 loc) · 13.8 KB
/
buffer_tools.py
File metadata and controls
407 lines (356 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
"""Agent-namespaced tmux paste buffer tools.
Tmux paste buffers are server-global: every buffer lives in a single
flat namespace shared by all clients on that tmux server. If two MCP
agents — or two parallel tool calls from one agent — independently
created a buffer named ``clipboard`` they would silently overwrite
each other's content.
To make buffers safe for concurrent use, every ``load_buffer`` call
allocates a unique name of the form::
libtmux_mcp_<uuid4hex>_<logical_name>
and returns the full name in a :class:`BufferRef` so the caller can
round-trip with :func:`paste_buffer`, :func:`show_buffer`, and
:func:`delete_buffer` without ambiguity.
``list_buffers`` is **not** exposed in the default safety tier —
buffer contents often include the user's OS clipboard history (passwords,
private snippets), and a blanket enumeration would leak that to the
agent. Callers track the buffers they own via the ``BufferRef``s
returned from ``load_buffer``.
"""
from __future__ import annotations
import pathlib
import re
import subprocess
import tempfile
import typing as t
import uuid
from fastmcp.exceptions import ToolError
from libtmux_mcp._utils import (
ANNOTATIONS_MUTATING,
ANNOTATIONS_RO,
ANNOTATIONS_SHELL,
TAG_MUTATING,
TAG_READONLY,
_get_server,
_resolve_pane,
_tmux_argv,
handle_tool_errors,
)
from libtmux_mcp.models import BufferContent, BufferRef
from libtmux_mcp.tools.pane_tools.io import (
CAPTURE_DEFAULT_MAX_LINES,
_truncate_lines_tail,
)
#: Default line cap for :func:`show_buffer`. Reuses the scrollback
#: default so agents see one consistent bound across read-heavy tools.
SHOW_BUFFER_DEFAULT_MAX_LINES = CAPTURE_DEFAULT_MAX_LINES
if t.TYPE_CHECKING:
from fastmcp import FastMCP
#: Reserved prefix for MCP-allocated buffers. Anything matching this
#: regex is considered agent-owned; anything else is the human user's
#: buffer (including OS-clipboard sync buffers) and must not be exposed.
_MCP_BUFFER_PREFIX = "libtmux_mcp_"
#: Full-shape validator for MCP-allocated buffer names. Caller-provided
#: logical names are restricted to a conservative alphabet so the final
#: name is stable and safe to pass to ``tmux load-buffer -b``.
_BUFFER_NAME_RE = re.compile(
r"^libtmux_mcp_[0-9a-f]{32}_[A-Za-z0-9_.-]{1,64}$",
)
#: Validator for the caller-supplied logical portion of a buffer name.
#: Empty logical names are replaced with ``buf`` to avoid a trailing
#: underscore in the allocated name.
_LOGICAL_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
def _validate_logical_name(name: str) -> str:
"""Return ``name`` unchanged if it is a valid logical portion.
Empty strings collapse to ``"buf"`` before validation because
tmux-side buffer names must contain at least one character after
the UUID separator.
Examples
--------
>>> _validate_logical_name("my-buffer")
'my-buffer'
>>> _validate_logical_name("clipboard.v2")
'clipboard.v2'
>>> _validate_logical_name("")
'buf'
>>> _validate_logical_name("has space")
Traceback (most recent call last):
...
fastmcp.exceptions.ToolError: Invalid logical buffer name: 'has space'
>>> _validate_logical_name("with/slash")
Traceback (most recent call last):
...
fastmcp.exceptions.ToolError: Invalid logical buffer name: 'with/slash'
"""
if name == "":
return "buf"
if not _LOGICAL_NAME_RE.fullmatch(name):
msg = f"Invalid logical buffer name: {name!r}"
raise ToolError(msg)
return name
def _validate_buffer_name(name: str) -> str:
"""Return ``name`` unchanged if it is a well-formed MCP buffer name.
Rejects names outside the MCP namespace so the tool surface cannot
be tricked into reading or clobbering buffers the agent did not
allocate. This is the main defence against the "clipboard privacy"
risk documented at the module level.
Examples
--------
>>> _validate_buffer_name("libtmux_mcp_00112233445566778899aabbccddeeff_buf")
'libtmux_mcp_00112233445566778899aabbccddeeff_buf'
>>> _validate_buffer_name("clipboard")
Traceback (most recent call last):
...
fastmcp.exceptions.ToolError: Invalid buffer name: 'clipboard'
>>> _validate_buffer_name("libtmux_mcp_shortuuid_buf")
Traceback (most recent call last):
...
fastmcp.exceptions.ToolError: Invalid buffer name: 'libtmux_mcp_shortuuid_buf'
"""
if not _BUFFER_NAME_RE.fullmatch(name):
msg = f"Invalid buffer name: {name!r}"
raise ToolError(msg)
return name
def _allocate_buffer_name(logical_name: str | None) -> str:
"""Allocate a unique MCP buffer name for a caller's logical label.
The returned name always has the shape
``libtmux_mcp_<32-hex-uuid>_<logical_name>`` — the prefix defends
the tool surface against interacting with buffers it did not
create (OS-clipboard sync populates tmux's server-global namespace
too), and the uuid nonce prevents collisions when multiple agents
or parallel tool calls allocate buffers at the same time. When
``logical_name`` is empty or ``None``, ``"buf"`` is substituted
to avoid a trailing-underscore name.
Examples
--------
>>> name = _allocate_buffer_name("clip")
>>> name.startswith("libtmux_mcp_")
True
>>> name.endswith("_clip")
True
>>> # 32 hex characters between the prefix and the logical suffix.
>>> len(name.removeprefix("libtmux_mcp_").rsplit("_", 1)[0])
32
Empty logical name collapses to ``"buf"``:
>>> _allocate_buffer_name("").endswith("_buf")
True
>>> _allocate_buffer_name(None).endswith("_buf")
True
"""
base = _validate_logical_name(logical_name or "")
return f"{_MCP_BUFFER_PREFIX}{uuid.uuid4().hex}_{base}"
@handle_tool_errors
def load_buffer(
content: str,
logical_name: str | None = None,
socket_name: str | None = None,
) -> BufferRef:
"""Load text into a new agent-namespaced tmux paste buffer.
Track the returned BufferRef on subsequent paste_buffer / show_buffer
/ delete_buffer calls — there is no list_buffers tool, because tmux
buffers may include OS clipboard history (passwords, private
snippets) and a blanket enumeration would leak that to the agent.
Each call allocates a fresh buffer name — two concurrent calls will
land in distinct buffers even if they pass the same ``logical_name``.
Agents MUST use the returned :attr:`BufferRef.buffer_name` on
subsequent paste/show/delete calls.
**When to use this vs. paste_text:** ``load_buffer`` is the
stage-then-fire path — you get a handle back and can inspect via
``show_buffer``, paste into multiple panes via ``paste_buffer``,
or hold the content for later. Use ``paste_text`` for a simple
one-shot paste with no follow-up.
Parameters
----------
content : str
The text to stage. Can be multi-line. Redacted in audit logs.
logical_name : str, optional
Short label for the buffer. Limited to
``[A-Za-z0-9_.-]{1,64}`` so the final name stays safe on the
tmux command line. Empty or ``None`` uses ``"buf"``.
socket_name : str, optional
tmux socket name.
Returns
-------
BufferRef
Handle with the allocated ``buffer_name`` the caller must use
on follow-up calls.
"""
server = _get_server(socket_name=socket_name)
buffer_name = _allocate_buffer_name(logical_name)
tmppath: str | None = None
try:
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
tmppath = f.name
f.write(content)
argv = _tmux_argv(server, "load-buffer", "-b", buffer_name, tmppath)
try:
subprocess.run(argv, check=True, capture_output=True, timeout=5.0)
except subprocess.TimeoutExpired as e:
msg = f"load-buffer timeout after 5s for {buffer_name!r}"
raise ToolError(msg) from e
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode(errors="replace").strip() if e.stderr else ""
msg = f"load-buffer failed: {stderr or e}"
raise ToolError(msg) from e
finally:
if tmppath is not None:
pathlib.Path(tmppath).unlink(missing_ok=True)
return BufferRef(buffer_name=buffer_name, logical_name=logical_name)
@handle_tool_errors
def paste_buffer(
buffer_name: str,
pane_id: str | None = None,
bracket: bool = True,
session_name: str | None = None,
session_id: str | None = None,
window_id: str | None = None,
socket_name: str | None = None,
) -> str:
"""Paste an MCP-owned buffer into a pane.
Parameters
----------
buffer_name : str
Must match the full MCP-namespaced form returned by
:func:`load_buffer`. Non-MCP buffers are rejected so the tool
cannot be turned into an arbitrary-buffer reader.
pane_id : str, optional
Target pane ID.
bracket : bool
Use tmux bracketed paste mode. Default True.
session_name, session_id, window_id : optional
Pane resolution fallbacks.
socket_name : str, optional
tmux socket name.
Returns
-------
str
Confirmation message naming the target pane.
"""
server = _get_server(socket_name=socket_name)
cname = _validate_buffer_name(buffer_name)
pane = _resolve_pane(
server,
pane_id=pane_id,
session_name=session_name,
session_id=session_id,
window_id=window_id,
)
paste_args: list[str] = ["-b", cname]
if bracket:
paste_args.append("-p")
paste_args.extend(["-t", pane.pane_id or ""])
pane.cmd("paste-buffer", *paste_args)
return f"Buffer {cname!r} pasted to pane {pane.pane_id}"
@handle_tool_errors
def show_buffer(
buffer_name: str,
max_lines: int | None = SHOW_BUFFER_DEFAULT_MAX_LINES,
socket_name: str | None = None,
) -> BufferContent:
"""Read back the contents of an MCP-owned buffer.
Output is tail-preserved: when the buffer exceeds ``max_lines`` the
oldest lines are dropped and :attr:`BufferContent.content_truncated`
is set so the caller can tell truncation happened and opt in to a
full read via ``max_lines=None``. This mirrors ``capture_pane`` —
one consistent bounded-output contract across read-heavy tools so
a pathological ``load_buffer`` staging cannot blow the agent's
context window on a single ``show_buffer`` call.
Parameters
----------
buffer_name : str
Must match the full MCP-namespaced form.
max_lines : int or None
Maximum number of lines to return. Defaults to
:data:`SHOW_BUFFER_DEFAULT_MAX_LINES`. Pass ``None`` for no
truncation.
socket_name : str, optional
tmux socket name.
Returns
-------
BufferContent
Structured result with ``buffer_name``, ``content``, and the
truncation fields.
"""
server = _get_server(socket_name=socket_name)
cname = _validate_buffer_name(buffer_name)
argv = _tmux_argv(server, "show-buffer", "-b", cname)
try:
completed = subprocess.run(
argv,
check=True,
capture_output=True,
timeout=5.0,
)
except subprocess.TimeoutExpired as e:
msg = f"show-buffer timeout after 5s for {cname!r}"
raise ToolError(msg) from e
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode(errors="replace").strip() if e.stderr else ""
msg = f"show-buffer failed for {cname!r}: {stderr or e}"
raise ToolError(msg) from e
raw = completed.stdout.decode(errors="replace")
# Preserve a possible trailing newline so round-tripping through
# load_buffer/show_buffer stays byte-identical when truncation
# does not fire.
lines = raw.splitlines()
kept, truncated, dropped = _truncate_lines_tail(lines, max_lines)
content = "\n".join(kept) if truncated else raw
return BufferContent(
buffer_name=cname,
content=content,
content_truncated=truncated,
content_truncated_lines=dropped,
)
@handle_tool_errors
def delete_buffer(
buffer_name: str,
socket_name: str | None = None,
) -> str:
"""Delete an MCP-owned buffer.
Parameters
----------
buffer_name : str
Must match the full MCP-namespaced form.
socket_name : str, optional
tmux socket name.
Returns
-------
str
Confirmation message.
"""
server = _get_server(socket_name=socket_name)
cname = _validate_buffer_name(buffer_name)
argv = _tmux_argv(server, "delete-buffer", "-b", cname)
try:
subprocess.run(argv, check=True, capture_output=True, timeout=5.0)
except subprocess.TimeoutExpired as e:
msg = f"delete-buffer timeout after 5s for {cname!r}"
raise ToolError(msg) from e
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode(errors="replace").strip() if e.stderr else ""
msg = f"delete-buffer failed for {cname!r}: {stderr or e}"
raise ToolError(msg) from e
return f"Buffer {cname!r} deleted"
def register(mcp: FastMCP) -> None:
"""Register buffer tools with the MCP instance.
``load_buffer`` is tagged with :data:`ANNOTATIONS_SHELL` because its
``content`` argument is arbitrary user text that may carry
interactive-environment side effects (commands about to be pasted
into a shell). Other buffer tools are plain mutating ops on the
tmux buffer store.
"""
mcp.tool(title="Load Buffer", annotations=ANNOTATIONS_SHELL, tags={TAG_MUTATING})(
load_buffer
)
mcp.tool(
title="Paste Buffer",
annotations=ANNOTATIONS_SHELL,
tags={TAG_MUTATING},
)(paste_buffer)
mcp.tool(title="Show Buffer", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})(
show_buffer
)
mcp.tool(
title="Delete Buffer",
annotations=ANNOTATIONS_MUTATING,
tags={TAG_MUTATING},
)(delete_buffer)