From 22f7dd556f1be11dbef8e0d7e52398927902eb8b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 19:08:46 -0500 Subject: [PATCH 01/20] mcp(feat[history]): Add suppression policy why: Let semantic MCP commands inherit a strict startup history default without changing raw terminal input or direct Python call behavior. what: - Resolve and publish LIBTMUX_SUPPRESS_HISTORY for run_command - Keep raw send, batch, and paste input explicit-only - Add MCP, Bash history, instruction-budget, and boundary regression tests --- src/libtmux_mcp/_history.py | 39 ++ src/libtmux_mcp/server.py | 64 +- src/libtmux_mcp/tools/pane_tools/io.py | 6 +- tests/test_history.py | 773 +++++++++++++++++++++++++ tests/test_server.py | 80 ++- 5 files changed, 946 insertions(+), 16 deletions(-) create mode 100644 src/libtmux_mcp/_history.py create mode 100644 tests/test_history.py diff --git a/src/libtmux_mcp/_history.py b/src/libtmux_mcp/_history.py new file mode 100644 index 00000000..6e781126 --- /dev/null +++ b/src/libtmux_mcp/_history.py @@ -0,0 +1,39 @@ +"""Semantic shell-history policy helpers.""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +def _resolve_suppress_history(value: str | None) -> bool: + """Resolve the strict startup history-suppression setting.""" + if value is None or value == "0": + return False + if value == "1": + return True + msg = "LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'" + raise ValueError(msg) + + +def _configure_history_defaults( + mcp: FastMCP, + enabled: bool, + *, + tool_names: tuple[str, ...] = ("run_command",), +) -> None: + """Publish the effective default for semantic MCP command tools.""" + from fastmcp.server.transforms import ToolTransform + from fastmcp.tools.tool_transform import ArgTransformConfig, ToolTransformConfig + + argument = ArgTransformConfig(default=enabled) + mcp.add_transform( + ToolTransform( + { + name: ToolTransformConfig(arguments={"suppress_history": argument}) + for name in tool_names + } + ) + ) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 849bb26b..c72a4314 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -18,6 +18,10 @@ from libtmux.server import Server from libtmux_mcp.__about__ import __version__ +from libtmux_mcp._history import ( + _configure_history_defaults, + _resolve_suppress_history, +) from libtmux_mcp._utils import ( TAG_DESTRUCTIVE, TAG_MUTATING, @@ -106,17 +110,16 @@ #: comment above for when to add another ``_GAP`` segment vs. push the #: explanation into a tool description. _INSTR_HOOKS_GAP = ( - "HOOKS ARE READ-ONLY: inspect via show_hooks / show_hook. " - "Write-hooks survive process death; keep them in your tmux config file, " - "not a transient MCP session." + "HOOKS ARE READ-ONLY: inspect via show_hooks/show_hook. " + "Write hooks survive process death; keep them in your tmux config file." ) #: Gap-explainer: ``list_buffers`` is intentionally absent because tmux #: buffers can include OS clipboard history. See module comment above. _INSTR_BUFFERS_GAP = ( "BUFFERS: load_buffer stages, paste_buffer delivers, delete_buffer " - "removes. Track via the BufferRef returned from load_buffer — no " - "list_buffers tool because tmux buffers may include OS clipboard history." + "removes via returned BufferRef. No list_buffers: tmux buffers may include " + "clipboard history." ) _BASE_INSTRUCTIONS = "\n\n".join( @@ -131,8 +134,13 @@ ) ) +_INSTRUCTIONS_MAX_BYTES = 2048 + -def _build_instructions(safety_level: str = TAG_MUTATING) -> str: +def _build_instructions( + safety_level: str = TAG_MUTATING, + suppress_history: bool = False, +) -> str: """Build server instructions with agent context and safety level. When the MCP server process runs inside a tmux pane, ``TMUX_PANE`` and @@ -143,6 +151,8 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: ---------- safety_level : str Active safety tier (readonly, mutating, or destructive). + suppress_history : bool + Effective MCP default for semantic shell-command suppression. Returns ------- @@ -157,6 +167,11 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: "(values: readonly, mutating, destructive). " "Set LIBTMUX_SAFETY; off-tier tools are hidden." ) + history_default = "true" if suppress_history else "false" + parts.append( + f"\n\nHistory: run_command suppression defaults {history_default}. " + "Raw send_keys/send_keys_batch/paste tools never inherit." + ) # Tier-conditioned discoverability hint. False-positive activation is # cheap on readonly (worst case: an extra list_panes call) and @@ -165,11 +180,18 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: # separate LIBTMUX_DISCOVERABILITY knob. if safety_level == TAG_READONLY: parts.append( - "\n\nReadonly mode: if uncertain, prefer " - "one read-only probe (snapshot_pane, list_panes, search_panes)." + "\n\nReadonly mode: probe snapshot_pane/list_panes/search_panes if unsure." ) - # Agent tmux context + instructions = "".join(parts) + if len(instructions.encode("utf-8")) > _INSTRUCTIONS_MAX_BYTES: + msg = "required server instructions exceed the 2048-byte MCP budget" + raise RuntimeError(msg) + + # Agent tmux context is optional. Prefer the complete form, then discard + # the untrusted socket name and explanatory workflow before omitting the + # context entirely. Never byte-slice text because UTF-8 characters may be + # split across bytes. tmux_pane = os.environ.get("TMUX_PANE") if tmux_pane: # Parse TMUX env: "/tmp/tmux-1000/default,48188,10" @@ -178,16 +200,25 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: socket_path = env_parts[0] if env_parts else None socket_name = socket_path.rsplit("/", 1)[-1] if socket_path else None - context = f"\n\nAgent context: this MCP runs inside tmux pane {tmux_pane}" + context_start = f"\n\nAgent context: this MCP runs inside tmux pane {tmux_pane}" + context = context_start if socket_name: context += f" (socket {socket_name})" context += ( ". Tool results mark is_caller=true; filter list_panes for it to answer " "'which pane am I in?' (no whoami tool)." ) - parts.append(context) + pane_context = ( + f"{context_start}. Tool results mark is_caller=true; filter list_panes " + "for it to answer 'which pane am I in?' (no whoami tool)." + ) + minimal_context = f"\n\nAgent context: tmux pane {tmux_pane}." + for candidate in (context, pane_context, minimal_context): + combined = instructions + candidate + if len(combined.encode("utf-8")) <= _INSTRUCTIONS_MAX_BYTES: + return combined - return "".join(parts) + return instructions def _resolve_safety_level(value: str | None) -> str: @@ -205,6 +236,9 @@ def _resolve_safety_level(value: str | None) -> str: _safety_level = _resolve_safety_level(os.environ.get("LIBTMUX_SAFETY")) +_suppress_history = _resolve_suppress_history( + os.environ.get("LIBTMUX_SUPPRESS_HISTORY") +) #: Tools covered by the tail-preserving response limiter. Only tools #: whose output is terminal scrollback benefit from this backstop; @@ -279,7 +313,10 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: mcp = FastMCP( name="tmux", version=__version__, - instructions=_build_instructions(safety_level=_safety_level), + instructions=_build_instructions( + safety_level=_safety_level, + suppress_history=_suppress_history, + ), website_url="https://libtmux-mcp.git-pull.com/", lifespan=_lifespan, # Middleware runs outermost-first. Order rationale: @@ -340,6 +377,7 @@ def _register_all() -> None: from libtmux_mcp.tools import register_tools register_tools(mcp) + _configure_history_defaults(mcp, _suppress_history) register_resources(mcp) register_prompts(mcp) _mcp_registered = True diff --git a/src/libtmux_mcp/tools/pane_tools/io.py b/src/libtmux_mcp/tools/pane_tools/io.py index a3738044..90ba50b6 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -362,8 +362,10 @@ async def run_command( Maximum pane output lines to return. Defaults to all captured visible output; pass a small value for a tail-only summary. suppress_history : bool - Suppress shell history by prepending a space; only effective where - the shell ignores space-prefixed commands. Default False. + For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY + default; an explicit value overrides it. Direct Python calls default + to False. Best effort: the shell must honor space-prefixed history + suppression. socket_name : str, optional tmux socket name. diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 00000000..55864293 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,773 @@ +"""Tests for semantic shell-history suppression policy.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import pathlib +import shlex +import subprocess +import sys +import textwrap +import typing as t + +import pytest +from fastmcp import Client, FastMCP +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + + +class SuppressHistorySettingFixture(t.NamedTuple): + """Fixture for valid ``LIBTMUX_SUPPRESS_HISTORY`` values.""" + + test_id: str + value: str | None + expected: bool + + +SUPPRESS_HISTORY_SETTING_FIXTURES = [ + SuppressHistorySettingFixture("unset", None, False), + SuppressHistorySettingFixture("disabled", "0", False), + SuppressHistorySettingFixture("enabled", "1", True), +] + + +class McpHistoryBehaviorFixture(t.NamedTuple): + """Fixture for effective MCP command behavior.""" + + test_id: str + value: str + omitted_is_saved: bool + + +MCP_HISTORY_BEHAVIOR_FIXTURES = [ + McpHistoryBehaviorFixture("startup_disabled", "0", True), + McpHistoryBehaviorFixture("startup_enabled", "1", False), +] + + +def _history_server(value: str) -> FastMCP: + """Build a focused MCP server with one resolved history transform.""" + from libtmux_mcp._history import ( + _configure_history_defaults, + _resolve_suppress_history, + ) + from libtmux_mcp.tools import batch_tools, buffer_tools, pane_tools + + mcp = FastMCP(f"history-{value}") + batch_tools.register(mcp) + buffer_tools.register(mcp) + pane_tools.register(mcp) + _configure_history_defaults(mcp, _resolve_suppress_history(value)) + return mcp + + +def _assert_run_command_succeeded(result: t.Any) -> None: + """Assert a semantic command completed instead of merely dispatching.""" + assert result.is_error is False + assert result.structured_content is not None + assert result.structured_content["timed_out"] is False + assert result.structured_content["exit_status"] == 0 + + +@pytest.mark.parametrize( + SuppressHistorySettingFixture._fields, + SUPPRESS_HISTORY_SETTING_FIXTURES, + ids=[fixture.test_id for fixture in SUPPRESS_HISTORY_SETTING_FIXTURES], +) +def test_resolve_suppress_history_setting( + test_id: str, + value: str | None, + expected: bool, +) -> None: + """The startup setting accepts unset, ``0``, and ``1``.""" + from libtmux_mcp._history import _resolve_suppress_history + + assert test_id + assert _resolve_suppress_history(value) is expected + + +def test_resolve_suppress_history_rejects_invalid_without_echoing_value() -> None: + """Invalid startup input raises fixed, non-reflective guidance.""" + from libtmux_mcp._history import _resolve_suppress_history + + rejected = "private-invalid-setting" + expected = "LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'" + + with pytest.raises(ValueError) as excinfo: + _resolve_suppress_history(rejected) + + assert str(excinfo.value) == expected + assert rejected not in str(excinfo.value) + + +@pytest.mark.parametrize("enabled", [False, True], ids=["disabled", "enabled"]) +def test_history_transform_publishes_non_nullable_run_command_default( + enabled: bool, +) -> None: + """The MCP schema publishes the resolved semantic-command default.""" + from libtmux_mcp._history import _configure_history_defaults + from libtmux_mcp.tools import pane_tools + + mcp = FastMCP("history-schema-probe") + pane_tools.register(mcp) + _configure_history_defaults(mcp, enabled) + + async def _list_tools() -> dict[str, t.Any]: + async with Client(mcp) as client: + return {tool.name: tool for tool in await client.list_tools()} + + tools = asyncio.run(_list_tools()) + schema = tools["run_command"].inputSchema["properties"]["suppress_history"] + + assert schema["type"] == "boolean" + assert schema["default"] is enabled + assert "anyOf" not in schema + + +@pytest.mark.parametrize( + SuppressHistorySettingFixture._fields, + SUPPRESS_HISTORY_SETTING_FIXTURES, + ids=[fixture.test_id for fixture in SUPPRESS_HISTORY_SETTING_FIXTURES], +) +def test_production_mcp_schema_uses_startup_history_default( + test_id: str, + value: str | None, + expected: bool, +) -> None: + """A fresh server publishes one stable transform with retained metadata.""" + script = textwrap.dedent( + """ + import asyncio + import json + import logging + + from fastmcp import Client + from libtmux_mcp.server import build_mcp_server + + logging.disable(logging.CRITICAL) + + async def main(): + first = build_mcp_server() + before = len(first.transforms) + second = build_mcp_server() + after = len(second.transforms) + async with Client(first) as client: + tools = {tool.name: tool for tool in await client.list_tools()} + tool = tools["run_command"] + schema = tool.inputSchema["properties"]["suppress_history"] + print(json.dumps({ + "same_server": first is second, + "transform_counts": [before, after], + "schema": schema, + "annotations": tool.annotations.model_dump( + mode="json", exclude_none=True + ), + "tags": tool.meta["fastmcp"]["tags"], + }, sort_keys=True)) + + asyncio.run(main()) + """ + ) + env = os.environ.copy() + if value is None: + env.pop("LIBTMUX_SUPPRESS_HISTORY", None) + else: + env["LIBTMUX_SUPPRESS_HISTORY"] = value + + completed = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + env=env, + text=True, + ) + payload = json.loads(completed.stdout.splitlines()[-1]) + + assert test_id + assert payload["same_server"] is True + assert payload["transform_counts"][0] == payload["transform_counts"][1] + assert payload["schema"]["type"] == "boolean" + assert payload["schema"]["default"] is expected + assert "anyOf" not in payload["schema"] + assert payload["annotations"] == { + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": True, + "readOnlyHint": False, + } + assert payload["tags"] == ["mutating"] + + +def test_invalid_history_setting_fails_server_startup_without_echoing_value() -> None: + """Invalid explicit privacy configuration prevents server startup.""" + rejected = "private-invalid-setting" + env = {**os.environ, "LIBTMUX_SUPPRESS_HISTORY": rejected} + completed = subprocess.run( + [sys.executable, "-c", "import libtmux_mcp.server"], + check=False, + capture_output=True, + env=env, + text=True, + ) + + assert completed.returncode != 0 + assert "LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'" in completed.stderr + assert rejected not in completed.stderr + + +def test_run_command_describes_mcp_precedence_and_direct_python_default() -> None: + """The tool description distinguishes MCP omission from Python calls.""" + from libtmux_mcp.tools import pane_tools + + expected = ( + "For MCP calls, omission uses the server's " + "LIBTMUX_SUPPRESS_HISTORY default; an explicit value overrides it. " + "Direct Python calls default to False. Best effort: the shell must honor " + "space-prefixed history suppression." + ) + parameter = inspect.signature(pane_tools.run_command).parameters["suppress_history"] + mcp = FastMCP("run-command-description") + pane_tools.register(mcp) + tools = {tool.name: tool for tool in asyncio.run(mcp.list_tools())} + schema = tools["run_command"].parameters["properties"]["suppress_history"] + + assert parameter.annotation == "bool" + assert parameter.default is False + assert " ".join(schema["description"].split()) == expected + + +@pytest.mark.parametrize( + McpHistoryBehaviorFixture._fields, + MCP_HISTORY_BEHAVIOR_FIXTURES, + ids=[fixture.test_id for fixture in MCP_HISTORY_BEHAVIOR_FIXTURES], +) +def test_mcp_run_command_and_generic_batch_use_effective_default( + test_id: str, + value: str, + omitted_is_saved: bool, + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """Omission inherits through direct and generic MCP calls; booleans win.""" + histfile = tmp_path / f"{test_id}.bash_history" + mcp_pane.send_keys("exec bash --noprofile --norc", enter=True) + retry_until( + lambda: any("bash-" in line for line in mcp_pane.capture_pane()), + 2, + raises=True, + ) + setup = ( + f"HISTFILE={shlex.quote(str(histfile))}; " + "HISTCONTROL=ignorespace; set -o history; history -c; history -w" + ) + mcp_pane.send_keys(setup, enter=True) + retry_until(histfile.exists, 2, raises=True) + + omitted = f"MCP_OMITTED_{test_id}" + explicit_true = f"MCP_TRUE_{test_id}" + explicit_false = f"MCP_FALSE_{test_id}" + batch_omitted = f"MCP_BATCH_OMITTED_{test_id}" + flushed_marker = f"MCP_FLUSHED_{test_id}" + omitted_output = tmp_path / f"{test_id}-omitted.txt" + explicit_true_output = tmp_path / f"{test_id}-true.txt" + explicit_false_output = tmp_path / f"{test_id}-false.txt" + batch_output = tmp_path / f"{test_id}-batch.txt" + flushed_output = tmp_path / f"{test_id}-flushed.txt" + base = { + "pane_id": mcp_pane.pane_id, + "timeout": 3.0, + "socket_name": mcp_server.socket_name, + } + + def _write_command(marker: str, output: pathlib.Path) -> str: + return f"printf '%s\\n' {shlex.quote(marker)} > {shlex.quote(str(output))}" + + async def _exercise() -> None: + async with Client(_history_server(value)) as client: + calls = ( + ( + {**base, "command": _write_command(omitted, omitted_output)}, + omitted_output, + omitted, + ), + ( + { + **base, + "command": _write_command( + explicit_true, + explicit_true_output, + ), + "suppress_history": True, + }, + explicit_true_output, + explicit_true, + ), + ( + { + **base, + "command": _write_command( + explicit_false, + explicit_false_output, + ), + "suppress_history": False, + }, + explicit_false_output, + explicit_false, + ), + ) + for arguments, output, marker in calls: + result = await client.call_tool( + "run_command", + arguments, + raise_on_error=False, + ) + _assert_run_command_succeeded(result) + assert output.read_text() == f"{marker}\n" + + batch = await client.call_tool( + "call_mutating_tools_batch", + { + "operations": [ + { + "tool": "run_command", + "arguments": { + **base, + "command": _write_command( + batch_omitted, + batch_output, + ), + }, + } + ] + }, + raise_on_error=False, + ) + assert batch.is_error is False + assert batch.structured_content is not None + assert batch.structured_content["succeeded"] == 1 + [operation] = batch.structured_content["results"] + assert operation["success"] is True + nested = operation["structured_content"] + assert nested is not None + assert nested["timed_out"] is False + assert nested["exit_status"] == 0 + assert batch_output.read_text() == f"{batch_omitted}\n" + + flushed = await client.call_tool( + "run_command", + { + **base, + "command": ( + f"history -w; {_write_command(flushed_marker, flushed_output)}" + ), + "suppress_history": True, + }, + raise_on_error=False, + ) + _assert_run_command_succeeded(flushed) + assert flushed_output.read_text() == f"{flushed_marker}\n" + + asyncio.run(_exercise()) + saved = histfile.read_text() + + assert (omitted in saved) is omitted_is_saved + assert explicit_true not in saved + assert explicit_false in saved + assert (batch_omitted in saved) is omitted_is_saved + + +def test_global_history_transform_keeps_raw_and_paste_schemas_explicit_only() -> None: + """Raw input defaults stay false and paste tools gain no policy argument.""" + + async def _list_tools() -> dict[str, t.Any]: + async with Client(_history_server("1")) as client: + return {tool.name: tool for tool in await client.list_tools()} + + tools = asyncio.run(_list_tools()) + run_command = tools["run_command"].inputSchema["properties"] + send_keys = tools["send_keys"].inputSchema["properties"] + operation = tools["send_keys_batch"].inputSchema["properties"]["operations"][ + "items" + ]["properties"] + + assert run_command["suppress_history"]["type"] == "boolean" + assert run_command["suppress_history"]["default"] is True + assert "anyOf" not in run_command["suppress_history"] + assert send_keys["suppress_history"]["default"] is False + assert operation["suppress_history"]["default"] is False + assert "suppress_history" not in tools["paste_text"].inputSchema["properties"] + assert "suppress_history" not in tools["paste_buffer"].inputSchema["properties"] + + +def test_global_history_default_leaves_raw_send_keys_bytes_and_boundaries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Control/TUI input stays exact; explicit suppression adds one space. + + A fake pane delegates to libtmux's real ``send_keys`` at the pre-PTY + command boundary, where an inherited prefix or merged Enter is observable. + """ + from libtmux import Pane + + from libtmux_mcp.tools.pane_tools import io + + calls: list[tuple[str, tuple[str, ...]]] = [] + + class FakeServer: + tmux_bin = "tmux" + socket_name = None + socket_path = None + + class FakePane: + pane_id = "%1" + server = FakeServer() + + def cmd(self, *args: str) -> None: + calls.append(("cmd", args)) + + def enter(self) -> None: + calls.append(("enter", ())) + + def send_keys(self, keys: str, **kwargs: t.Any) -> None: + Pane.send_keys(t.cast("Pane", self), keys, **kwargs) + + pane = FakePane() + monkeypatch.setattr(io, "_get_server", lambda **kwargs: FakeServer()) + monkeypatch.setattr(io, "_resolve_pane", lambda *args, **kwargs: pane) + + async def _exercise() -> None: + async with Client(_history_server("1")) as client: + requests = ( + {"keys": "C-c", "enter": False}, + {"keys": "partial-TUI", "enter": False, "literal": True}, + {"keys": "/needle", "enter": True}, + { + "keys": "explicit-secret", + "enter": True, + "literal": True, + "suppress_history": True, + }, + ) + for arguments in requests: + result = await client.call_tool( + "send_keys", + arguments, + raise_on_error=False, + ) + assert result.is_error is False + + asyncio.run(_exercise()) + + assert calls == [ + ("cmd", ("send-keys", "C-c")), + ("cmd", ("send-keys", "-l", "partial-TUI")), + ("cmd", ("send-keys", "/needle")), + ("enter", ()), + ("cmd", ("send-keys", "-l", " explicit-secret")), + ("enter", ()), + ] + + +def test_global_history_default_leaves_untimed_batch_operations_explicit_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Untimed batches preserve raw defaults, literal mode, and Enter. + + A fake pane exercises libtmux's real ``send_keys`` at the pre-PTY command + boundary so exact literal bytes and the separate Enter call remain visible. + """ + from libtmux import Pane + + from libtmux_mcp.tools.pane_tools import io + + calls: list[tuple[str, tuple[str, ...]]] = [] + + class FakeServer: + tmux_bin = "tmux" + socket_name = None + socket_path = None + + class FakePane: + pane_id = "%1" + server = FakeServer() + + def cmd(self, *args: str) -> None: + calls.append(("cmd", args)) + + def enter(self) -> None: + calls.append(("enter", ())) + + def send_keys(self, keys: str, **kwargs: t.Any) -> None: + Pane.send_keys(t.cast("Pane", self), keys, **kwargs) + + pane = FakePane() + monkeypatch.setattr(io, "_get_server", lambda **kwargs: FakeServer()) + monkeypatch.setattr(io, "_resolve_pane", lambda *args, **kwargs: pane) + + async def _exercise() -> None: + async with Client(_history_server("1")) as client: + result = await client.call_tool( + "send_keys_batch", + { + "operations": [ + {"keys": "C-c", "enter": False}, + { + "keys": "TUI_BATCH_DEFAULT", + "enter": True, + "literal": True, + }, + { + "keys": "batch-secret", + "enter": True, + "literal": True, + "suppress_history": True, + }, + ] + }, + raise_on_error=False, + ) + assert result.is_error is False + + asyncio.run(_exercise()) + + assert calls == [ + ("cmd", ("send-keys", "C-c")), + ("cmd", ("send-keys", "-l", "TUI_BATCH_DEFAULT")), + ("enter", ()), + ("cmd", ("send-keys", "-l", " batch-secret")), + ("enter", ()), + ] + + +def test_global_history_default_leaves_timed_batch_operations_explicit_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Timed batches preserve raw bytes and send Enter separately. + + Timed batches bypass ``Pane.send_keys``, so subprocess interception at the + pre-PTY argv boundary is required to expose prefixes and Enter coalescing. + """ + from libtmux_mcp.tools.pane_tools import io + + calls: list[list[str]] = [] + + class FakeServer: + tmux_bin = "tmux" + socket_name = None + socket_path = None + + class FakePane: + pane_id = "%1" + server = FakeServer() + + def _run(argv: list[str], **kwargs: t.Any) -> subprocess.CompletedProcess[str]: + calls.append(argv) + return subprocess.CompletedProcess(argv, 0) + + pane = FakePane() + monkeypatch.setattr(io, "_get_server", lambda **kwargs: FakeServer()) + monkeypatch.setattr(io, "_resolve_pane", lambda *args, **kwargs: pane) + monkeypatch.setattr("libtmux_mcp.tools.pane_tools.io.subprocess.run", _run) + + async def _exercise() -> None: + async with Client(_history_server("1")) as client: + result = await client.call_tool( + "send_keys_batch", + { + "operations": [ + {"keys": "C-c", "enter": False}, + { + "keys": "TUI_BATCH_DEFAULT", + "enter": True, + "literal": True, + }, + { + "keys": "batch-secret", + "enter": True, + "literal": True, + "suppress_history": True, + }, + ], + "timeout": 5.0, + }, + raise_on_error=False, + ) + assert result.is_error is False + + asyncio.run(_exercise()) + + assert calls == [ + ["tmux", "send-keys", "-t", "%1", "C-c"], + ["tmux", "send-keys", "-t", "%1", "-l", "TUI_BATCH_DEFAULT"], + ["tmux", "send-keys", "-t", "%1", "Enter"], + ["tmux", "send-keys", "-t", "%1", "-l", " batch-secret"], + ["tmux", "send-keys", "-t", "%1", "Enter"], + ] + + +def test_global_history_default_leaves_paste_payloads_and_calls_unchanged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Paste tools preserve exact text and their existing buffer semantics. + + Subprocess and fake-pane interception observe the staged payload and paste + call at the pre-PTY boundary, before tmux can obscure an inherited prefix. + """ + from libtmux_mcp.tools import buffer_tools + from libtmux_mcp.tools.pane_tools import io + + loaded_text: list[str] = [] + paste_calls: list[tuple[str, bool, bool]] = [] + + class FakeServer: + tmux_bin = "tmux" + socket_name = None + socket_path = None + + def delete_buffer(self, *, buffer_name: str) -> None: + return None + + class FakePane: + pane_id = "%1" + + def paste_buffer( + self, + *, + buffer_name: str, + bracket: bool, + delete_after: bool = False, + ) -> None: + paste_calls.append((buffer_name, bracket, delete_after)) + + def _run(argv: list[str], **kwargs: t.Any) -> subprocess.CompletedProcess[str]: + if "load-buffer" in argv: + loaded_text.append(pathlib.Path(argv[-1]).read_text()) + return subprocess.CompletedProcess(argv, 0) + + server = FakeServer() + pane = FakePane() + monkeypatch.setattr(io, "_get_server", lambda **kwargs: server) + monkeypatch.setattr(io, "_resolve_pane", lambda *args, **kwargs: pane) + monkeypatch.setattr("libtmux_mcp.tools.pane_tools.io.subprocess.run", _run) + monkeypatch.setattr(buffer_tools, "_get_server", lambda **kwargs: server) + monkeypatch.setattr(buffer_tools, "_resolve_pane", lambda *args, **kwargs: pane) + + raw_text = "C-c\npartial TUI text\n" + existing_buffer = f"libtmux_mcp_{'a' * 32}_buf" + + async def _exercise() -> None: + async with Client(_history_server("1")) as client: + pasted_text = await client.call_tool( + "paste_text", + {"text": raw_text, "bracket": False}, + raise_on_error=False, + ) + assert pasted_text.is_error is False + pasted_buffer = await client.call_tool( + "paste_buffer", + {"buffer_name": existing_buffer, "bracket": True}, + raise_on_error=False, + ) + assert pasted_buffer.is_error is False + + asyncio.run(_exercise()) + + assert loaded_text == [raw_text] + assert len(paste_calls) == 2 + assert paste_calls[0][1:] == (False, True) + assert paste_calls[1] == (existing_buffer, True, False) + + +def test_mcp_run_command_protects_complete_multiline_bash_history_event( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """One grouped multiline command executes fully without disk history.""" + histfile = tmp_path / "multiline.bash_history" + mcp_pane.send_keys("exec bash --noprofile --norc", enter=True) + retry_until( + lambda: any("bash-" in line for line in mcp_pane.capture_pane()), + 2, + raises=True, + ) + setup = ( + f"HISTFILE={shlex.quote(str(histfile))}; " + "HISTCONTROL=ignorespace; shopt -s cmdhist; shopt -u lithist; " + "set -o history; history -c; history -w" + ) + mcp_pane.send_keys(setup, enter=True) + retry_until(histfile.exists, 2, raises=True) + + control_first = "MULTILINE_CONTROL_FIRST" + control_second = "MULTILINE_CONTROL_SECOND" + protected_first = "MULTILINE_PROTECTED_FIRST" + protected_second = "MULTILINE_PROTECTED_SECOND" + control_output = tmp_path / "multiline-control.txt" + protected_output = tmp_path / "multiline-protected.txt" + control_command = ( + f"printf '%s\\n' {control_first} > {shlex.quote(str(control_output))}\n" + f"printf '%s\\n' {control_second} >> {shlex.quote(str(control_output))}" + ) + protected_command = ( + f"printf '%s\\n' {protected_first} > {shlex.quote(str(protected_output))}\n" + f"printf '%s\\n' {protected_second} >> {shlex.quote(str(protected_output))}" + ) + base = { + "pane_id": mcp_pane.pane_id, + "timeout": 3.0, + "socket_name": mcp_server.socket_name, + } + + async def _exercise() -> None: + async with Client(_history_server("1")) as client: + control = await client.call_tool( + "run_command", + { + **base, + "command": control_command, + "suppress_history": False, + }, + raise_on_error=False, + ) + _assert_run_command_succeeded(control) + protected = await client.call_tool( + "run_command", + {**base, "command": protected_command}, + raise_on_error=False, + ) + _assert_run_command_succeeded(protected) + flushed = await client.call_tool( + "run_command", + { + **base, + "command": "history -w", + "suppress_history": True, + }, + raise_on_error=False, + ) + _assert_run_command_succeeded(flushed) + + asyncio.run(_exercise()) + saved_lines = histfile.read_text().splitlines() + control_entries = [ + line for line in saved_lines if control_first in line or control_second in line + ] + + assert control_output.read_text().splitlines() == [control_first, control_second] + assert protected_output.read_text().splitlines() == [ + protected_first, + protected_second, + ] + assert len(control_entries) == 1 + assert control_first in control_entries[0] + assert control_second in control_entries[0] + assert all(protected_first not in line for line in saved_lines) + assert all(protected_second not in line for line in saved_lines) diff --git a/tests/test_server.py b/tests/test_server.py index 023e406b..e310a027 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -401,6 +401,29 @@ def test_build_instructions_always_includes_safety() -> None: assert "LIBTMUX_SAFETY" in result +@pytest.mark.parametrize( + ("suppress_history", "expected_default"), + [(False, "false"), (True, "true")], + ids=["history-disabled", "history-enabled"], +) +def test_build_instructions_documents_semantic_history_default_and_raw_boundary( + suppress_history: bool, + expected_default: str, +) -> None: + """Instructions expose the active semantic default and raw exclusion.""" + instructions = _build_instructions(suppress_history=suppress_history) + + assert ( + f"History: run_command suppression defaults {expected_default}. " + "Raw send_keys/send_keys_batch/paste tools never inherit." + ) in instructions + + +@pytest.mark.parametrize( + "suppress_history", + [False, True], + ids=["history-disabled", "history-enabled"], +) @pytest.mark.parametrize( ("tier", "tmux_pane", "tmux_env"), [ @@ -423,6 +446,7 @@ def test_build_instructions_always_includes_safety() -> None: ) def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( monkeypatch: pytest.MonkeyPatch, + suppress_history: bool, tier: str, tmux_pane: str, tmux_env: str, @@ -449,7 +473,10 @@ def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) - instructions = _build_instructions(safety_level=tier) + instructions = _build_instructions( + safety_level=tier, + suppress_history=suppress_history, + ) size = len(instructions.encode()) assert size <= 2048, ( f"tier={tier} tmux_pane={tmux_pane!r}: " @@ -457,6 +484,57 @@ def test_full_instructions_under_2kb_across_tiers_and_tmux_pane( ) +@pytest.mark.parametrize( + "socket_name", + ["s" * 4096, "界" * 2048], + ids=["long-ascii", "long-multibyte"], +) +def test_instruction_budget_drops_oversized_socket_before_required_text( + monkeypatch: pytest.MonkeyPatch, + socket_name: str, +) -> None: + """Untrusted socket names cannot displace required UTF-8 instructions.""" + monkeypatch.setenv("TMUX_PANE", "%42") + monkeypatch.setenv("TMUX", f"/tmp/tmux-1000/{socket_name},12345,0") + + instructions = _build_instructions( + safety_level=TAG_READONLY, + suppress_history=True, + ) + + assert len(instructions.encode("utf-8")) <= 2048 + assert _BASE_INSTRUCTIONS in instructions + assert ( + "History: run_command suppression defaults true. " + "Raw send_keys/send_keys_batch/paste tools never inherit." + ) in instructions + assert "Agent context" in instructions + assert "%42" in instructions + assert socket_name not in instructions + + +def test_instruction_budget_can_drop_all_oversized_optional_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Required instructions survive when even pane-only context is too large.""" + oversized_pane = "%" + ("界" * 2048) + monkeypatch.setenv("TMUX_PANE", oversized_pane) + monkeypatch.setenv("TMUX", f"/tmp/tmux-1000/{'s' * 4096},12345,0") + + instructions = _build_instructions( + safety_level=TAG_READONLY, + suppress_history=False, + ) + + assert len(instructions.encode("utf-8")) <= 2048 + assert _BASE_INSTRUCTIONS in instructions + assert ( + "History: run_command suppression defaults false. " + "Raw send_keys/send_keys_batch/paste tools never inherit." + ) in instructions + assert "Agent context" not in instructions + + def test_base_instructions_document_scope() -> None: """``_BASE_INSTRUCTIONS`` carries an activation rule with anti-triggers. From 9abd91c0bfbd795836ab3d959459d2787b3364ca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 20:04:28 -0500 Subject: [PATCH 02/20] mcp(feat[spawn]): Add history-safe launches why: Let MCP-spawned shells inherit the configured history policy without rewriting launch commands or mutating caller environments. what: - Normalize and merge controls across all four spawn tools - Preserve positional compatibility and tmux environment scope - Cover schemas, conflicts, forwarding, and Bash/Zsh/Fish behavior --- src/libtmux_mcp/_history.py | 89 +- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 16 +- src/libtmux_mcp/tools/server_tools.py | 25 +- src/libtmux_mcp/tools/session_tools.py | 16 + src/libtmux_mcp/tools/window_tools.py | 16 + tests/test_history.py | 197 ++++- tests/test_spawn_shell_history.py | 340 ++++++++ tests/test_spawn_tools_history.py | 778 ++++++++++++++++++ 8 files changed, 1447 insertions(+), 30 deletions(-) create mode 100644 tests/test_spawn_shell_history.py create mode 100644 tests/test_spawn_tools_history.py diff --git a/src/libtmux_mcp/_history.py b/src/libtmux_mcp/_history.py index 6e781126..8a81d104 100644 --- a/src/libtmux_mcp/_history.py +++ b/src/libtmux_mcp/_history.py @@ -8,6 +8,15 @@ from fastmcp import FastMCP +_HISTORY_DEFAULT_TOOLS = ( + "run_command", + "create_session", + "create_window", + "split_window", + "respawn_pane", +) + + def _resolve_suppress_history(value: str | None) -> bool: """Resolve the strict startup history-suppression setting.""" if value is None or value == "0": @@ -22,9 +31,20 @@ def _configure_history_defaults( mcp: FastMCP, enabled: bool, *, - tool_names: tuple[str, ...] = ("run_command",), + tool_names: tuple[str, ...] = _HISTORY_DEFAULT_TOOLS, ) -> None: - """Publish the effective default for semantic MCP command tools.""" + """Publish the effective MCP default for semantic command and spawn tools. + + Parameters + ---------- + mcp : FastMCP + Server receiving the public tool transform. + enabled : bool + Effective startup default to publish. + tool_names : tuple[str, ...] + Semantic tool names that inherit the default when omitted by an MCP + caller. + """ from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ArgTransformConfig, ToolTransformConfig @@ -37,3 +57,68 @@ def _configure_history_defaults( } ) ) + + +def _prepare_spawn_environment( + environment: dict[str, str] | str | None, + *, + suppress_history: bool, +) -> dict[str, str] | None: + """Copy and normalize an environment for a newly spawned process. + + Parameters + ---------- + environment : dict, str, or None + Environment mapping or JSON-object string supplied by the caller. + suppress_history : bool + Whether to merge the best-effort shell-history controls. + + Returns + ------- + dict or None + A copied environment, or ``None`` when the caller supplied no + environment and suppression is disabled. + + Raises + ------ + ExpectedToolError + If keys or values are not strings, or a caller value conflicts with a + required history control. + """ + from libtmux_mcp._utils import ExpectedToolError, _coerce_dict_arg + + coerced = _coerce_dict_arg("environment", environment) + if coerced is None: + result: dict[str, str] = {} + else: + if any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in coerced.items() + ): + msg = "environment keys and values must be strings" + raise ExpectedToolError(msg) + result = dict(coerced) + + if not suppress_history: + return None if coerced is None else result + + required_values = { + "HISTFILE": "", + "fish_history": "", + "fish_private_mode": "1", + } + for name, required in required_values.items(): + if name in result and result[name] != required: + msg = ( + f"environment variable {name} conflicts with " + "suppress_history=True; omit it or use the required empty value" + ) + raise ExpectedToolError(msg) + result[name] = required + + history_control = result.get("HISTCONTROL", "") + tokens = [token for token in history_control.split(":") if token] + if "ignorespace" not in tokens and "ignoreboth" not in tokens: + tokens.append("ignorespace") + result["HISTCONTROL"] = ":".join(tokens) + return result diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index a522b260..8c434c6a 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -4,6 +4,7 @@ import typing as t +from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( ExpectedToolError, _caller_is_on_server, @@ -69,8 +70,10 @@ def respawn_pane( kill: bool = True, shell: str | None = None, start_directory: str | None = None, - environment: dict[str, str] | None = None, + environment: dict[str, str] | str | None = None, socket_name: str | None = None, + *, + suppress_history: bool = False, ) -> PaneInfo: """Restart a pane's process in place, preserving pane_id and layout. @@ -117,7 +120,7 @@ def respawn_pane( start_directory : str, optional Working directory for the relaunched command (maps to ``respawn-pane -c``). - environment : dict[str, str], optional + environment : dict or str, optional Environment variables to set for the relaunched process. Each item becomes one ``-e KEY=VALUE`` flag (tmux's ``cmd-respawn-pane.c`` supports the flag repeatedly). Values @@ -129,6 +132,9 @@ def respawn_pane( host-resident agent or other tenant could observe ``ps``. socket_name : str, optional tmux socket name. + suppress_history : bool + Request best-effort shell-history suppression for the relaunched + process. Direct Python calls default to False. Returns ------- @@ -136,6 +142,10 @@ def respawn_pane( Serialized pane metadata after respawn. The pane_id is preserved; pane_pid reflects the new process. """ + spawn_environment = _prepare_spawn_environment( + environment, + suppress_history=suppress_history, + ) server = _get_server(socket_name=socket_name) pane = _resolve_pane(server, pane_id=pane_id) caller = _get_caller_identity() @@ -152,7 +162,7 @@ def respawn_pane( pane.respawn( kill=kill, start_directory=start_directory, - environment=environment, + environment=spawn_environment, shell=shell, ) # Pick up fresh pane_pid and any command/path updates; tmux does diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index 9b0b9a2f..5f9936d0 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -11,6 +11,7 @@ from fastmcp.exceptions import ToolError +from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, @@ -21,7 +22,6 @@ ExpectedToolError, _apply_filters, _caller_is_on_server, - _coerce_dict_arg, _get_caller_identity, _get_server, _invalidate_server, @@ -75,11 +75,14 @@ def create_session( y: int | None = None, environment: dict[str, str] | str | None = None, socket_name: str | None = None, + *, + suppress_history: bool = False, ) -> SessionInfo: """Create a new tmux session. Check list_sessions first to avoid name conflicts. A new session - starts with one window and one pane. + starts with one window and one pane. Values in ``environment`` are stored + in the tmux session environment, so future panes inherit them too. Parameters ---------- @@ -94,18 +97,25 @@ def create_session( y : int, optional Height of the initial window. environment : dict or str, optional - Environment variables to set. Accepts either a dict of env - vars or a JSON-serialized string of the same — the latter is - the cursor-composer-1 workaround described in + Environment variables to store in the session environment. Accepts + either a dict of env vars or a JSON-serialized string of the same — + the latter is the cursor-composer-1 workaround described in :func:`libtmux_mcp._utils._coerce_dict_arg`. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. + suppress_history : bool + Request best-effort shell-history suppression for the initial shell + and future panes. Direct Python calls default to False. Returns ------- SessionInfo The created session. """ + spawn_environment = _prepare_spawn_environment( + environment, + suppress_history=suppress_history, + ) server = _get_server(socket_name=socket_name) kwargs: dict[str, t.Any] = {} if session_name is not None: @@ -118,9 +128,8 @@ def create_session( kwargs["x"] = x if y is not None: kwargs["y"] = y - coerced_env = _coerce_dict_arg("environment", environment) - if coerced_env is not None: - kwargs["environment"] = coerced_env + if spawn_environment is not None: + kwargs["environment"] = spawn_environment session = server.new_session(**kwargs) return _serialize_session(session) diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index f2391abb..dad44f8f 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -6,6 +6,7 @@ from libtmux.constants import WindowDirection +from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, @@ -117,6 +118,9 @@ def create_window( attach: bool = False, direction: t.Literal["before", "after"] | None = None, socket_name: str | None = None, + *, + environment: dict[str, str] | str | None = None, + suppress_history: bool = False, ) -> WindowInfo: """Create a new window in a tmux session. @@ -138,12 +142,22 @@ def create_window( Window placement direction. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. + environment : dict or str, optional + Per-process environment as a mapping or JSON object string. Values do + not modify the tmux session environment. + suppress_history : bool + Request best-effort shell-history suppression for the spawned shell. + Direct Python calls default to False. Returns ------- WindowInfo Serialized window object. """ + spawn_environment = _prepare_spawn_environment( + environment, + suppress_history=suppress_history, + ) server = _get_server(socket_name=socket_name) session = _resolve_session(server, session_name=session_name, session_id=session_id) kwargs: dict[str, t.Any] = {} @@ -163,6 +177,8 @@ def create_window( msg = f"Invalid direction: {direction!r}. Valid: {valid}" raise ExpectedToolError(msg) kwargs["direction"] = resolved + if spawn_environment is not None: + kwargs["environment"] = spawn_environment window = session.new_window(**kwargs) return _serialize_window(window) diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 7dcd6908..4abe9ff3 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -6,6 +6,7 @@ from libtmux.constants import PaneDirection +from libtmux_mcp._history import _prepare_spawn_environment from libtmux_mcp._utils import ( ANNOTATIONS_CREATE, ANNOTATIONS_DESTRUCTIVE, @@ -162,6 +163,9 @@ def split_window( start_directory: str | None = None, shell: str | None = None, socket_name: str | None = None, + *, + environment: dict[str, str] | str | None = None, + suppress_history: bool = False, ) -> PaneInfo: """Split a tmux window to create a new pane. @@ -191,12 +195,22 @@ def split_window( Shell command to run in the new pane. socket_name : str, optional tmux socket name. + environment : dict or str, optional + Per-process environment as a mapping or JSON object string. Values do + not modify the tmux session environment. + suppress_history : bool + Request best-effort shell-history suppression for the spawned shell. + Direct Python calls default to False. Returns ------- PaneInfo Serialized pane object. """ + spawn_environment = _prepare_spawn_environment( + environment, + suppress_history=suppress_history, + ) server = _get_server(socket_name=socket_name) pane_dir: PaneDirection | None = None @@ -214,6 +228,7 @@ def split_window( size=size, start_directory=start_directory, shell=shell, + environment=spawn_environment, ) else: window = _resolve_window( @@ -228,6 +243,7 @@ def split_window( size=size, start_directory=start_directory, shell=shell, + environment=spawn_environment, ) return _serialize_pane(new_pane) diff --git a/tests/test_history.py b/tests/test_history.py index 55864293..6b25be6b 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -106,28 +106,191 @@ def test_resolve_suppress_history_rejects_invalid_without_echoing_value() -> Non assert rejected not in str(excinfo.value) -@pytest.mark.parametrize("enabled", [False, True], ids=["disabled", "enabled"]) -def test_history_transform_publishes_non_nullable_run_command_default( - enabled: bool, -) -> None: - """The MCP schema publishes the resolved semantic-command default.""" +def test_history_transform_changes_exact_semantic_tool_set() -> None: + """Only run-command and the four spawn tools inherit one boolean default.""" from libtmux_mcp._history import _configure_history_defaults - from libtmux_mcp.tools import pane_tools - - mcp = FastMCP("history-schema-probe") - pane_tools.register(mcp) - _configure_history_defaults(mcp, enabled) + from libtmux_mcp.tools import register_tools + + semantic_tools = { + "run_command", + "create_session", + "create_window", + "split_window", + "respawn_pane", + } - async def _list_tools() -> dict[str, t.Any]: + async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: + mcp = FastMCP(f"history-transform-{enabled}") + register_tools(mcp) + _configure_history_defaults(mcp, enabled) async with Client(mcp) as client: - return {tool.name: tool for tool in await client.list_tools()} + tools = await client.list_tools() + return { + tool.name: tool.inputSchema["properties"]["suppress_history"] + for tool in tools + if "suppress_history" in tool.inputSchema["properties"] + } + + disabled = asyncio.run(_schemas(False)) + enabled = asyncio.run(_schemas(True)) + changed = { + name + for name, schema in enabled.items() + if schema["default"] != disabled[name]["default"] + } - tools = asyncio.run(_list_tools()) - schema = tools["run_command"].inputSchema["properties"]["suppress_history"] + assert changed == semantic_tools + for default, schemas in ((False, disabled), (True, enabled)): + for name in semantic_tools: + schema = schemas[name] + assert schema["type"] == "boolean" + assert schema["default"] is default + assert "anyOf" not in schema + + +class SpawnEnvironmentFixture(t.NamedTuple): + """Fixture for successful spawn-environment preparation.""" + + test_id: str + environment: dict[str, str] | str | None + suppress_history: bool + expected: dict[str, str] | None + + +SPAWN_ENVIRONMENT_FIXTURES = [ + SpawnEnvironmentFixture("none_disabled", None, False, None), + SpawnEnvironmentFixture("empty_disabled", {}, False, {}), + SpawnEnvironmentFixture("dict_copied", {"FOO": "bar"}, False, {"FOO": "bar"}), + SpawnEnvironmentFixture("json_normalized", '{"FOO":"bar"}', False, {"FOO": "bar"}), + SpawnEnvironmentFixture( + "enabled_defaults", + None, + True, + { + "HISTFILE": "", + "HISTCONTROL": "ignorespace", + "fish_private_mode": "1", + "fish_history": "", + }, + ), + SpawnEnvironmentFixture( + "history_control_merged", + {"FOO": "bar", "HISTCONTROL": "ignoredups"}, + True, + { + "FOO": "bar", + "HISTFILE": "", + "HISTCONTROL": "ignoredups:ignorespace", + "fish_private_mode": "1", + "fish_history": "", + }, + ), + SpawnEnvironmentFixture( + "history_control_ignorespace_preserved", + {"HISTCONTROL": "erasedups:ignorespace"}, + True, + { + "HISTFILE": "", + "HISTCONTROL": "erasedups:ignorespace", + "fish_private_mode": "1", + "fish_history": "", + }, + ), + SpawnEnvironmentFixture( + "history_control_ignoreboth_preserved", + {"HISTCONTROL": "ignoreboth"}, + True, + { + "HISTFILE": "", + "HISTCONTROL": "ignoreboth", + "fish_private_mode": "1", + "fish_history": "", + }, + ), +] + + +@pytest.mark.parametrize( + SpawnEnvironmentFixture._fields, + SPAWN_ENVIRONMENT_FIXTURES, + ids=[fixture.test_id for fixture in SPAWN_ENVIRONMENT_FIXTURES], +) +def test_prepare_spawn_environment_normalizes_copies_and_merges( + test_id: str, + environment: dict[str, str] | str | None, + suppress_history: bool, + expected: dict[str, str] | None, +) -> None: + """Spawn environments are normalized without modifying caller input.""" + from libtmux_mcp._history import _prepare_spawn_environment + + assert test_id + original = environment.copy() if isinstance(environment, dict) else environment + result = _prepare_spawn_environment(environment, suppress_history=suppress_history) + + assert result == expected + if isinstance(environment, dict): + assert environment == original + if result is not None: + assert result is not environment + + +@pytest.mark.parametrize( + ("name", "supplied"), + [ + ("HISTFILE", "private-bash-history-path"), + ("fish_history", "private-fish-history-name"), + ("fish_private_mode", "private-fish-mode-value"), + ], +) +def test_prepare_spawn_environment_rejects_conflicts_without_values( + name: str, + supplied: str, +) -> None: + """Policy conflicts identify only the variable, never its supplied value.""" + from fastmcp.exceptions import ToolError + + from libtmux_mcp._history import _prepare_spawn_environment + + environment = {"UNCHANGED": "caller", name: supplied} + original = environment.copy() + expected = ( + f"environment variable {name} conflicts with suppress_history=True; " + "omit it or use the required empty value" + ) + + with pytest.raises(ToolError) as excinfo: + _prepare_spawn_environment(environment, suppress_history=True) + + assert str(excinfo.value) == expected + assert supplied not in str(excinfo.value) + assert environment == original + + +@pytest.mark.parametrize( + "environment", + [ + t.cast("t.Any", {1: "value"}), + t.cast("t.Any", {"NAME": 1}), + '{"NAME":1}', + ], + ids=["non_string_key", "non_string_value", "json_non_string_value"], +) +def test_prepare_spawn_environment_rejects_non_string_items( + environment: dict[str, str] | str, +) -> None: + """Tmux environment keys and values must both be strings.""" + from fastmcp.exceptions import ToolError + + from libtmux_mcp._history import _prepare_spawn_environment + + original = environment.copy() if isinstance(environment, dict) else environment + with pytest.raises(ToolError) as excinfo: + _prepare_spawn_environment(environment, suppress_history=False) - assert schema["type"] == "boolean" - assert schema["default"] is enabled - assert "anyOf" not in schema + assert str(excinfo.value) == "environment keys and values must be strings" + if isinstance(environment, dict): + assert environment == original @pytest.mark.parametrize( diff --git a/tests/test_spawn_shell_history.py b/tests/test_spawn_shell_history.py new file mode 100644 index 00000000..28d0c490 --- /dev/null +++ b/tests/test_spawn_shell_history.py @@ -0,0 +1,340 @@ +"""Functional evidence for history controls on spawned shells.""" + +from __future__ import annotations + +import pathlib +import shlex +import shutil +import typing as t + +import pytest +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + + +def _exercise_spawned_shell_history( + *, + binary: str, + arguments: tuple[str, ...], + config_command: t.Callable[[pathlib.Path], str], + memory_command: t.Callable[[pathlib.Path], str], + disk_history: t.Callable[[pathlib.Path, pathlib.Path], pathlib.Path], + sentinel: str, + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> tuple[str, str, str | None]: + """Run one controlled interactive shell and return its history evidence.""" + from libtmux_mcp.tools.window_tools import split_window + + executable = shutil.which(binary) + if executable is None: + pytest.skip(f"{binary} is unavailable; shell history evidence requires it") + + home = tmp_path / f"{binary}-home" + data_home = tmp_path / f"{binary}-data" + home.mkdir() + data_home.mkdir() + config_path = tmp_path / f"{binary}-configured.txt" + memory_path = tmp_path / f"{binary}-memory.txt" + shell = shlex.join((executable, *arguments)) + + window = mcp_pane.window + window.cmd("set-option", "-w", "remain-on-exit", "on") + pane_info = split_window( + pane_id=mcp_pane.pane_id, + shell=shell, + socket_name=mcp_server.socket_name, + environment={"HOME": str(home), "XDG_DATA_HOME": str(data_home)}, + suppress_history=True, + ) + assert pane_info.pane_id is not None + pane = mcp_server.panes.get(pane_id=pane_info.pane_id, default=None) + assert pane is not None + try: + + def _shell_is_ready() -> bool: + pane.refresh() + return pane.pane_current_command == binary + + retry_until(_shell_is_ready, 3, raises=True) + pane.send_keys(config_command(config_path), enter=True) + retry_until(config_path.exists, 3, raises=True) + pane.send_keys( + f"printf '%s\\n' {shlex.quote(sentinel)} >/dev/null", + enter=True, + ) + pane.send_keys(memory_command(memory_path), enter=True) + retry_until(memory_path.exists, 3, raises=True) + pane.send_keys("exit", enter=True) + + def _pane_is_dead() -> bool: + rendered = pane.cmd("display-message", "-p", "#{pane_dead}").stdout + return bool(rendered) and rendered[0].strip() == "1" + + retry_until(_pane_is_dead, 3, raises=True) + disk_path = disk_history(home, data_home) + disk_text = disk_path.read_text() if disk_path.exists() else None + return config_path.read_text(), memory_path.read_text(), disk_text + finally: + pane.kill() + window.cmd("set-option", "-wu", "remain-on-exit") + + +def test_spawned_bash_configures_disk_suppression_with_memory_caveat( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """Bash receives controls, writes no disk history, but retains memory.""" + sentinel = "BASH_SPAWN_HISTORY_SENTINEL" + configured, memory, disk = _exercise_spawned_shell_history( + binary="bash", + arguments=("--noprofile", "--norc"), + config_command=lambda path: ( + "printf '%s\\n' \"HISTFILE=<$HISTFILE>\" " + f'"HISTCONTROL=$HISTCONTROL" > {shlex.quote(str(path))}' + ), + memory_command=lambda path: f"history > {shlex.quote(str(path))}", + disk_history=lambda home, _data: home / ".bash_history", + sentinel=sentinel, + mcp_server=mcp_server, + mcp_pane=mcp_pane, + tmp_path=tmp_path, + ) + + assert "HISTFILE=<>" in configured + history_control = configured.split("HISTCONTROL=", 1)[1].strip().split(":") + assert "ignorespace" in history_control or "ignoreboth" in history_control + assert sentinel in memory + assert disk is None or sentinel not in disk + + +def test_spawned_zsh_configures_disk_suppression_with_memory_caveat( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """Zsh receives an empty HISTFILE, writes no disk history, but keeps memory.""" + sentinel = "ZSH_SPAWN_HISTORY_SENTINEL" + configured, memory, disk = _exercise_spawned_shell_history( + binary="zsh", + arguments=("-f",), + config_command=lambda path: ( + f"printf '%s\\n' \"HISTFILE=<$HISTFILE>\" > {shlex.quote(str(path))}" + ), + memory_command=lambda path: f"fc -l -20 > {shlex.quote(str(path))}", + disk_history=lambda home, _data: home / ".zsh_history", + sentinel=sentinel, + mcp_server=mcp_server, + mcp_pane=mcp_pane, + tmp_path=tmp_path, + ) + + assert configured.strip() == "HISTFILE=<>" + assert sentinel in memory + assert disk is None or sentinel not in disk + + +def test_spawned_fish_configures_private_disk_suppression_with_memory_caveat( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """Fish receives private controls, writes no disk history, but keeps memory.""" + sentinel = "FISH_SPAWN_HISTORY_SENTINEL" + configured, memory, disk = _exercise_spawned_shell_history( + binary="fish", + arguments=("--no-config",), + config_command=lambda path: ( + "printf 'fish_history=<%s>\\nfish_private_mode=<%s>\\n' " + f'"$fish_history" "$fish_private_mode" > {shlex.quote(str(path))}' + ), + memory_command=lambda path: f"history > {shlex.quote(str(path))}", + disk_history=lambda _home, data: data / "fish" / "fish_history", + sentinel=sentinel, + mcp_server=mcp_server, + mcp_pane=mcp_pane, + tmp_path=tmp_path, + ) + + assert "fish_history=<>" in configured + assert "fish_private_mode=<1>" in configured + assert sentinel in memory + assert disk is None or sentinel not in disk + + +def _exercise_shell_startup_override( + *, + binary: str, + configure: t.Callable[ + [pathlib.Path, pathlib.Path], + tuple[tuple[str, ...], dict[str, str], pathlib.Path], + ], + sentinel: str, + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> str | None: + """Prove a controlled startup file can override the environment policy.""" + from libtmux_mcp.tools.window_tools import split_window + + executable = shutil.which(binary) + if executable is None: + pytest.skip(f"{binary} is unavailable; startup override evidence requires it") + + home = tmp_path / f"{binary}-override-home" + data_home = tmp_path / f"{binary}-override-data" + home.mkdir() + data_home.mkdir() + arguments, extra_environment, disk_path = configure(home, data_home) + environment = { + "HOME": str(home), + "XDG_DATA_HOME": str(data_home), + **extra_environment, + } + + window = mcp_pane.window + window.cmd("set-option", "-w", "remain-on-exit", "on") + pane_info = split_window( + pane_id=mcp_pane.pane_id, + shell=shlex.join((executable, *arguments)), + socket_name=mcp_server.socket_name, + environment=environment, + suppress_history=True, + ) + assert pane_info.pane_id is not None + pane = mcp_server.panes.get(pane_id=pane_info.pane_id, default=None) + assert pane is not None + try: + + def _shell_is_ready() -> bool: + pane.refresh() + return pane.pane_current_command == binary + + retry_until(_shell_is_ready, 3, raises=True) + pane.send_keys( + f"printf '%s\\n' {shlex.quote(sentinel)} >/dev/null", + enter=True, + ) + pane.send_keys("exit", enter=True) + + def _pane_is_dead() -> bool: + rendered = pane.cmd("display-message", "-p", "#{pane_dead}").stdout + return bool(rendered) and rendered[0].strip() == "1" + + retry_until(_pane_is_dead, 3, raises=True) + return disk_path.read_text() if disk_path.exists() else None + finally: + pane.kill() + window.cmd("set-option", "-wu", "remain-on-exit") + + +def test_bash_startup_file_can_override_spawn_suppression( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """A Bash rc file can replace HISTFILE after the process starts.""" + sentinel = "BASH_STARTUP_OVERRIDE_SENTINEL" + + def _configure( + home: pathlib.Path, + _data_home: pathlib.Path, + ) -> tuple[tuple[str, ...], dict[str, str], pathlib.Path]: + history = home / "bash-override-history" + rcfile = home / ".bashrc" + rcfile.write_text( + f"export HISTFILE={shlex.quote(str(history))}\nset -o history\n" + ) + return ("--noprofile", "--rcfile", str(rcfile)), {}, history + + disk = _exercise_shell_startup_override( + binary="bash", + configure=_configure, + sentinel=sentinel, + mcp_server=mcp_server, + mcp_pane=mcp_pane, + tmp_path=tmp_path, + ) + + assert disk is not None + assert sentinel in disk + + +def test_zsh_startup_file_can_override_spawn_suppression( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """A Zsh rc file can replace HISTFILE after the process starts.""" + sentinel = "ZSH_STARTUP_OVERRIDE_SENTINEL" + + def _configure( + home: pathlib.Path, + _data_home: pathlib.Path, + ) -> tuple[tuple[str, ...], dict[str, str], pathlib.Path]: + history = home / "zsh-override-history" + (home / ".zshrc").write_text( + "\n".join( + ( + f"HISTFILE={shlex.quote(str(history))}", + "HISTSIZE=100", + "SAVEHIST=100", + "setopt inc_append_history", + "", + ) + ) + ) + return (), {"ZDOTDIR": str(home)}, history + + disk = _exercise_shell_startup_override( + binary="zsh", + configure=_configure, + sentinel=sentinel, + mcp_server=mcp_server, + mcp_pane=mcp_pane, + tmp_path=tmp_path, + ) + + assert disk is not None + assert sentinel in disk + + +def test_fish_startup_file_can_override_spawn_suppression( + mcp_server: Server, + mcp_pane: Pane, + tmp_path: pathlib.Path, +) -> None: + """A Fish config can replace the history name after process start.""" + sentinel = "FISH_STARTUP_OVERRIDE_SENTINEL" + + def _configure( + home: pathlib.Path, + data_home: pathlib.Path, + ) -> tuple[tuple[str, ...], dict[str, str], pathlib.Path]: + config_directory = home / ".config" / "fish" + config_directory.mkdir(parents=True) + (config_directory / "config.fish").write_text( + "set -g fish_history override\nset -e fish_private_mode\n" + ) + return ( + (), + {"XDG_CONFIG_HOME": str(home / ".config")}, + data_home / "fish" / "override_history", + ) + + disk = _exercise_shell_startup_override( + binary="fish", + configure=_configure, + sentinel=sentinel, + mcp_server=mcp_server, + mcp_pane=mcp_pane, + tmp_path=tmp_path, + ) + + assert disk is not None + assert sentinel in disk diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py new file mode 100644 index 00000000..7c89102c --- /dev/null +++ b/tests/test_spawn_tools_history.py @@ -0,0 +1,778 @@ +"""Tests for history-safe tmux spawn tool behavior.""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +import shlex +import typing as t + +import pytest +from fastmcp import Client, FastMCP +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + + +def test_spawn_tool_signatures_preserve_positional_slots() -> None: + """New spawn options are appended as keyword-only parameters.""" + from libtmux_mcp.tools.pane_tools import respawn_pane + from libtmux_mcp.tools.server_tools import create_session + from libtmux_mcp.tools.session_tools import create_window + from libtmux_mcp.tools.window_tools import split_window + + expected: dict[ + t.Callable[..., t.Any], + tuple[tuple[str, ...], tuple[str, ...]], + ] = { + create_session: ( + ( + "session_name", + "window_name", + "start_directory", + "x", + "y", + "environment", + "socket_name", + ), + ("suppress_history",), + ), + create_window: ( + ( + "session_name", + "session_id", + "window_name", + "start_directory", + "attach", + "direction", + "socket_name", + ), + ("environment", "suppress_history"), + ), + split_window: ( + ( + "pane_id", + "session_name", + "session_id", + "window_id", + "window_index", + "direction", + "size", + "start_directory", + "shell", + "socket_name", + ), + ("environment", "suppress_history"), + ), + respawn_pane: ( + ( + "pane_id", + "kill", + "shell", + "start_directory", + "environment", + "socket_name", + ), + ("suppress_history",), + ), + } + + for function, (positional_names, keyword_only_names) in expected.items(): + signature = inspect.signature(function) + parameters = signature.parameters + assert tuple(parameters) == positional_names + keyword_only_names + assert all( + parameters[name].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for name in positional_names + ) + assert all( + parameters[name].kind is inspect.Parameter.KEYWORD_ONLY + for name in keyword_only_names + ) + assert parameters["suppress_history"].default is False + bound = signature.bind(*([None] * len(positional_names))) + assert tuple(bound.arguments) == positional_names + with pytest.raises(TypeError): + signature.bind(*([None] * (len(positional_names) + 1))) + + +def test_spawn_environment_schemas_are_client_compatible() -> None: + """Every spawn tool accepts object and JSON-string environment input.""" + from libtmux_mcp.tools import register_tools + + mcp = FastMCP("spawn-environment-schema") + register_tools(mcp) + tools = {tool.name: tool for tool in asyncio.run(mcp.list_tools())} + + for name in ("create_session", "create_window", "split_window", "respawn_pane"): + environment = tools[name].parameters["properties"]["environment"] + assert {variant["type"] for variant in environment["anyOf"]} == { + "object", + "string", + "null", + } + suppress_history = tools[name].parameters["properties"]["suppress_history"] + assert suppress_history["type"] == "boolean" + assert suppress_history["default"] is False + + +def _assert_value_free_spawn_conflict(call: t.Callable[[], t.Any], name: str) -> None: + """Assert one spawn call rejects a policy conflict without its value.""" + from fastmcp.exceptions import ToolError + + supplied = "private-conflicting-history-value" + with pytest.raises(ToolError) as excinfo: + call() + + assert str(excinfo.value) == ( + f"environment variable {name} conflicts with suppress_history=True; " + "omit it or use the required empty value" + ) + assert supplied not in str(excinfo.value) + + +def test_spawn_validation_precedes_every_server_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A rejected environment performs zero tmux lookups or retry attempts.""" + from libtmux_mcp.tools import server_tools, session_tools, window_tools + from libtmux_mcp.tools.pane_tools import lifecycle + + calls: list[str] = [] + + def _unexpected_get_server(**_kwargs: t.Any) -> t.NoReturn: + calls.append("_get_server") + message = "spawn validation must precede _get_server" + raise AssertionError(message) + + for module in (server_tools, session_tools, window_tools, lifecycle): + monkeypatch.setattr(module, "_get_server", _unexpected_get_server) + + environment = {"HISTFILE": "private-conflicting-history-value"} + spawn_calls = ( + lambda: server_tools.create_session( + environment=environment, + suppress_history=True, + ), + lambda: session_tools.create_window( + environment=environment, + suppress_history=True, + ), + lambda: window_tools.split_window( + environment=environment, + suppress_history=True, + ), + lambda: lifecycle.respawn_pane( + pane_id="%1", + environment=environment, + suppress_history=True, + ), + ) + + for call in spawn_calls: + _assert_value_free_spawn_conflict(call, "HISTFILE") + + assert calls == [] + + +def test_spawn_conflicts_create_nothing_and_never_retry_unsuppressed( + mcp_server: Server, + mcp_session: Session, +) -> None: + """All spawn tools reject before creating or replacing a tmux process.""" + from libtmux_mcp.tools.pane_tools import respawn_pane + from libtmux_mcp.tools.server_tools import create_session + from libtmux_mcp.tools.session_tools import create_window + from libtmux_mcp.tools.window_tools import split_window + + supplied = "private-conflicting-history-value" + environment = {"HISTFILE": supplied} + + session_ids = {session.session_id for session in mcp_server.sessions} + _assert_value_free_spawn_conflict( + lambda: create_session( + session_name="history_conflict_session", + environment=environment, + socket_name=mcp_server.socket_name, + suppress_history=True, + ), + "HISTFILE", + ) + assert {session.session_id for session in mcp_server.sessions} == session_ids + + window_ids = {window.window_id for window in mcp_session.windows} + _assert_value_free_spawn_conflict( + lambda: create_window( + session_name=mcp_session.session_name, + window_name="history_conflict_window", + socket_name=mcp_server.socket_name, + environment=environment, + suppress_history=True, + ), + "HISTFILE", + ) + assert {window.window_id for window in mcp_session.windows} == window_ids + + window = mcp_session.active_window + pane_ids = {pane.pane_id for pane in window.panes} + _assert_value_free_spawn_conflict( + lambda: split_window( + window_id=window.window_id, + socket_name=mcp_server.socket_name, + environment=environment, + suppress_history=True, + ), + "HISTFILE", + ) + assert {pane.pane_id for pane in window.panes} == pane_ids + + pane = window.split(shell="sleep 30") + assert pane.pane_id is not None + pane.refresh() + original_pid = pane.pane_pid + try: + _assert_value_free_spawn_conflict( + lambda: respawn_pane( + pane_id=t.cast("str", pane.pane_id), + environment=environment, + socket_name=mcp_server.socket_name, + suppress_history=True, + ), + "HISTFILE", + ) + pane.refresh() + assert pane.pane_pid == original_pid + finally: + pane.kill() + + +def test_mcp_spawn_explicit_false_overrides_enabled_default( + mcp_server: Server, + mcp_session: Session, +) -> None: + """An explicit false override permits a caller history environment.""" + from libtmux_mcp._history import _configure_history_defaults + from libtmux_mcp.tools import session_tools + + mcp = FastMCP("spawn-override-probe") + session_tools.register(mcp) + _configure_history_defaults(mcp, True) + supplied = "private-explicit-history-path" + base = { + "session_name": mcp_session.session_name, + "environment": {"HISTFILE": supplied}, + "socket_name": mcp_server.socket_name, + } + before = {window.window_id for window in mcp_session.windows} + + async def _exercise() -> None: + async with Client(mcp) as client: + inherited = await client.call_tool( + "create_window", + {**base, "window_name": "spawn_default_conflict"}, + raise_on_error=False, + ) + assert inherited.is_error is True + assert inherited.content + error = inherited.content[0].text + assert error == ( + "environment variable HISTFILE conflicts with " + "suppress_history=True; omit it or use the required empty value" + ) + assert supplied not in error + assert {window.window_id for window in mcp_session.windows} == before + + overridden = await client.call_tool( + "create_window", + { + **base, + "window_name": "spawn_explicit_false", + "suppress_history": False, + }, + raise_on_error=False, + ) + assert overridden.is_error is False + assert overridden.structured_content is not None + assert overridden.structured_content["window_name"] == ( + "spawn_explicit_false" + ) + + asyncio.run(_exercise()) + created = [ + window for window in mcp_session.windows if window.window_id not in before + ] + assert len(created) == 1 + assert created[0].window_name == "spawn_explicit_false" + + +def _spawn_history_server(enabled: bool) -> FastMCP: + """Build a complete server for spawn transforms and generic batches.""" + from libtmux_mcp._history import _configure_history_defaults + from libtmux_mcp.tools import register_tools + + mcp = FastMCP(f"spawn-history-{enabled}") + register_tools(mcp) + _configure_history_defaults(mcp, enabled) + return mcp + + +def test_generic_batch_spawn_uses_default_and_explicit_false_override( + mcp_server: Server, + mcp_session: Session, +) -> None: + """Nested spawn calls retain transform defaults, errors, and inner state.""" + supplied = "private-nested-history-path" + before = {window.window_id for window in mcp_session.windows} + base = { + "session_name": mcp_session.session_name, + "environment": {"HISTFILE": supplied}, + "socket_name": mcp_server.socket_name, + } + + async def _exercise() -> t.Any: + async with Client(_spawn_history_server(True)) as client: + return await client.call_tool( + "call_mutating_tools_batch", + { + "on_error": "continue", + "operations": [ + { + "tool": "create_window", + "arguments": { + **base, + "window_name": "batch_spawn_default_conflict", + }, + }, + { + "tool": "create_window", + "arguments": { + **base, + "window_name": "batch_spawn_explicit_false", + "suppress_history": False, + }, + }, + ], + }, + raise_on_error=False, + ) + + result = asyncio.run(_exercise()) + + assert result.is_error is False + assert result.structured_content is not None + assert result.structured_content["succeeded"] == 1 + assert result.structured_content["failed"] == 1 + assert result.structured_content["stopped_at"] is None + failed, succeeded = result.structured_content["results"] + assert failed["success"] is False + assert failed["error"] == ( + "environment variable HISTFILE conflicts with suppress_history=True; " + "omit it or use the required empty value" + ) + assert supplied not in failed["error"] + assert succeeded["success"] is True + assert succeeded["structured_content"]["window_name"] == ( + "batch_spawn_explicit_false" + ) + created = {window.window_id for window in mcp_session.windows} - before + assert len(created) == 1 + + +def test_spawn_conflict_is_absent_from_tool_results_and_logs( + mcp_server: Server, + mcp_session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """Validation and audit surfaces never reproduce a conflicting value.""" + from libtmux_mcp.server import build_mcp_server + + supplied = "private-validation-log-sentinel" + + async def _exercise() -> t.Any: + async with Client(build_mcp_server()) as client: + return await client.call_tool( + "create_window", + { + "session_name": mcp_session.session_name, + "window_name": "spawn_log_conflict", + "environment": {"HISTFILE": supplied}, + "socket_name": mcp_server.socket_name, + "suppress_history": True, + }, + raise_on_error=False, + ) + + with caplog.at_level(logging.DEBUG): + result = asyncio.run(_exercise()) + + assert result.is_error is True + assert result.content + assert result.content[0].text == ( + "environment variable HISTFILE conflicts with suppress_history=True; " + "omit it or use the required empty value" + ) + assert supplied not in repr(result) + assert supplied not in "\n".join(record.getMessage() for record in caplog.records) + assert supplied not in repr([record.__dict__ for record in caplog.records]) + audit = [record for record in caplog.records if record.name == "libtmux_mcp.audit"] + assert audit + assert any( + "tool=create_window outcome=error" in record.getMessage() for record in audit + ) + + +def _assert_pane_environment( + pane: Pane, + *, + marker: str, + expected: str, +) -> None: + """Read an expanded environment tuple from a live spawned process.""" + pane.send_keys( + "printf '" + marker + ":<%s>|%s|%s|<%s>|%s\\n' " + '"$HISTFILE" "$HISTCONTROL" "$fish_private_mode" "$fish_history" ' + '"$SPAWN_SCOPE_MARKER"', + enter=True, + ) + retry_until( + lambda: any( + expected in line + for line in pane.cmd("capture-pane", "-p", "-S", "-50").stdout + ), + 3, + raises=True, + ) + + +def _session_environment(server: Server, session_name: str) -> list[str]: + """Return the complete tmux session environment.""" + return server.cmd("show-environment", "-t", session_name).stdout + + +def test_spawn_tools_forward_process_environment_without_session_leakage( + mcp_server: Server, + mcp_pane: Pane, +) -> None: + """Window, split, and respawn environments remain process-scoped.""" + from libtmux_mcp.tools.pane_tools import respawn_pane + from libtmux_mcp.tools.session_tools import create_window + from libtmux_mcp.tools.window_tools import split_window + + session_name = mcp_pane.session.session_name + assert session_name is not None + before = _session_environment(mcp_server, session_name) + assert all(not line.startswith("SPAWN_SCOPE_MARKER=") for line in before) + + window_info = create_window( + session_name=session_name, + window_name="history_process_window", + socket_name=mcp_server.socket_name, + environment='{"SPAWN_SCOPE_MARKER":"window"}', + suppress_history=True, + ) + assert window_info.active_pane_id is not None + window_pane = mcp_server.panes.get( + pane_id=window_info.active_pane_id, + default=None, + ) + assert window_pane is not None + window_pane.send_keys( + "printf 'WINDOW_PROCESS:%s\\n' \"$SPAWN_SCOPE_MARKER\"", + enter=True, + ) + retry_until( + lambda: any( + "WINDOW_PROCESS:window" in line for line in window_pane.capture_pane() + ), + 3, + raises=True, + ) + assert all( + not line.startswith("SPAWN_SCOPE_MARKER=") + for line in _session_environment(mcp_server, session_name) + ) + + pane_info = split_window( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + environment={ + "SPAWN_SCOPE_MARKER": "split", + "HISTCONTROL": "ignoredups", + }, + suppress_history=True, + ) + assert pane_info.pane_id is not None + split_pane = mcp_server.panes.get(pane_id=pane_info.pane_id, default=None) + assert split_pane is not None + _assert_pane_environment( + split_pane, + marker="SPLIT_PROCESS", + expected="SPLIT_PROCESS:<>|ignoredups:ignorespace|1|<>|split", + ) + assert all( + not line.startswith("SPAWN_SCOPE_MARKER=") + for line in _session_environment(mcp_server, session_name) + ) + + window_branch_info = split_window( + window_id=mcp_pane.window.window_id, + socket_name=mcp_server.socket_name, + environment={ + "SPAWN_SCOPE_MARKER": "window-branch", + "HISTCONTROL": "ignoredups", + }, + suppress_history=True, + ) + assert window_branch_info.pane_id is not None + window_branch_pane = mcp_server.panes.get( + pane_id=window_branch_info.pane_id, + default=None, + ) + assert window_branch_pane is not None + _assert_pane_environment( + window_branch_pane, + marker="WINDOW_BRANCH_PROCESS", + expected=("WINDOW_BRANCH_PROCESS:<>|ignoredups:ignorespace|1|<>|window-branch"), + ) + assert all( + not line.startswith("SPAWN_SCOPE_MARKER=") + for line in _session_environment(mcp_server, session_name) + ) + + later_pane_info = split_window( + window_id=mcp_pane.window.window_id, + socket_name=mcp_server.socket_name, + ) + assert later_pane_info.pane_id is not None + later_pane = mcp_server.panes.get( + pane_id=later_pane_info.pane_id, + default=None, + ) + assert later_pane is not None + later_pane.send_keys( + "printf 'LATER_PROCESS:<%s>\\n' \"$SPAWN_SCOPE_MARKER\"", + enter=True, + ) + retry_until( + lambda: any( + "LATER_PROCESS:<>" in line + for line in later_pane.cmd("capture-pane", "-p", "-S", "-50").stdout + ), + 3, + raises=True, + ) + + respawn_target = mcp_pane.window.split(shell="sleep 30") + assert respawn_target.pane_id is not None + try: + respawn_pane( + pane_id=respawn_target.pane_id, + shell="sh", + environment=('{"SPAWN_SCOPE_MARKER":"respawn","HISTCONTROL":"ignoredups"}'), + socket_name=mcp_server.socket_name, + suppress_history=True, + ) + _assert_pane_environment( + respawn_target, + marker="RESPAWN_PROCESS", + expected="RESPAWN_PROCESS:<>|ignoredups:ignorespace|1|<>|respawn", + ) + assert all( + not line.startswith("SPAWN_SCOPE_MARKER=") + for line in _session_environment(mcp_server, session_name) + ) + respawn_pane( + pane_id=respawn_target.pane_id, + shell="sh", + socket_name=mcp_server.socket_name, + ) + respawn_target.send_keys( + "printf 'LATER_RESPAWN:<%s>\\n' \"$SPAWN_SCOPE_MARKER\"", + enter=True, + ) + retry_until( + lambda: any( + "LATER_RESPAWN:<>" in line + for line in respawn_target.cmd( + "capture-pane", + "-p", + "-S", + "-50", + ).stdout + ), + 3, + raises=True, + ) + finally: + respawn_target.kill() + + +def test_create_session_history_environment_reaches_future_panes( + mcp_server: Server, +) -> None: + """Create-session stores policy values for the session and later panes.""" + from libtmux_mcp.tools.server_tools import create_session + + docstring = inspect.getdoc(create_session) or "" + assert "session environment" in docstring + assert "future panes" in docstring + + session_name = "history_session_scope" + create_session( + session_name=session_name, + environment={"SPAWN_SCOPE_MARKER": "session", "HISTCONTROL": "ignoredups"}, + socket_name=mcp_server.socket_name, + suppress_history=True, + ) + session = mcp_server.sessions.get(session_name=session_name, default=None) + assert session is not None + try: + rendered = _session_environment(mcp_server, session_name) + assert "SPAWN_SCOPE_MARKER=session" in rendered + assert "HISTFILE=" in rendered + assert "HISTCONTROL=ignoredups:ignorespace" in rendered + assert "fish_private_mode=1" in rendered + assert "fish_history=" in rendered + + shell = ( + 'sh -c \'printf "SESSION_FUTURE:<%s>|%s|%s|<%s>|%s\\\\n" ' + '"$HISTFILE" "$HISTCONTROL" "$fish_private_mode" "$fish_history" ' + '"$SPAWN_SCOPE_MARKER"; sleep 30\'' + ) + future_window = session.new_window(window_shell=shell) + future_pane = future_window.active_pane + assert future_pane is not None + retry_until( + lambda: any( + "SESSION_FUTURE:<>|ignoredups:ignorespace|1|<>|session" in line + for line in future_pane.capture_pane() + ), + 3, + raises=True, + ) + finally: + session.kill() + + +def test_explicit_false_keeps_inherited_session_history_environment( + mcp_server: Server, +) -> None: + """A process override does not erase policy already stored on its session.""" + from libtmux_mcp.tools.server_tools import create_session + from libtmux_mcp.tools.session_tools import create_window + + session_name = "history_explicit_false_inheritance" + create_session( + session_name=session_name, + environment={"HISTCONTROL": "ignoredups"}, + socket_name=mcp_server.socket_name, + suppress_history=True, + ) + session = mcp_server.sessions.get(session_name=session_name, default=None) + assert session is not None + try: + shell = ( + 'sh -c \'printf "EXPLICIT_FALSE_INHERITED:<%s>|%s|%s|<%s>|%s\\\\n" ' + '"$HISTFILE" "$HISTCONTROL" "$fish_private_mode" "$fish_history" ' + '"$EXPLICIT_FALSE_MARKER"; sleep 30\'' + ) + mcp_server.cmd( + "set-option", + "-t", + session_name, + "default-command", + shell, + ) + window_info = create_window( + session_name=session_name, + window_name="explicit_false_inherited", + socket_name=mcp_server.socket_name, + environment={"EXPLICIT_FALSE_MARKER": "process"}, + suppress_history=False, + ) + assert window_info.active_pane_id is not None + pane = mcp_server.panes.get( + pane_id=window_info.active_pane_id, + default=None, + ) + assert pane is not None + retry_until( + lambda: any( + "EXPLICIT_FALSE_INHERITED:<>|ignoredups:ignorespace|1|<>|process" + in line + for line in pane.capture_pane() + ), + 3, + raises=True, + ) + rendered = _session_environment(mcp_server, session_name) + assert "HISTFILE=" in rendered + assert "HISTCONTROL=ignoredups:ignorespace" in rendered + assert "EXPLICIT_FALSE_MARKER=process" not in rendered + finally: + session.kill() + + +def test_spawn_tools_preserve_direct_launch_strings( + mcp_server: Server, + mcp_pane: Pane, + caplog: pytest.LogCaptureFixture, +) -> None: + """History environment flags never rewrite direct tmux launch strings.""" + from libtmux_mcp.tools.pane_tools import respawn_pane + from libtmux_mcp.tools.window_tools import split_window + + pane_branch_shell = "sh -c 'sleep 30'" + window_branch_shell = "sh -c 'sleep 31'" + with caplog.at_level(logging.DEBUG, logger="libtmux.common"): + pane_branch = split_window( + pane_id=mcp_pane.pane_id, + shell=pane_branch_shell, + socket_name=mcp_server.socket_name, + suppress_history=True, + ) + window_branch = split_window( + window_id=mcp_pane.window.window_id, + shell=window_branch_shell, + socket_name=mcp_server.socket_name, + suppress_history=True, + ) + split_commands = [ + shlex.split(t.cast("str", record.__dict__["tmux_cmd"])) + for record in caplog.records + if " split-window " in record.__dict__.get("tmux_cmd", "") + and record.getMessage() == "tmux command dispatched" + ] + assert [command[-1] for command in split_commands] == [ + pane_branch_shell, + window_branch_shell, + ] + + assert pane_branch.pane_id is not None + assert window_branch.pane_id is not None + respawn_shell = "sh -c 'sleep 29'" + caplog.clear() + try: + with caplog.at_level(logging.DEBUG, logger="libtmux.common"): + respawn_pane( + pane_id=pane_branch.pane_id, + shell=respawn_shell, + socket_name=mcp_server.socket_name, + suppress_history=True, + ) + respawn_commands = [ + shlex.split(t.cast("str", record.__dict__["tmux_cmd"])) + for record in caplog.records + if " respawn-pane " in record.__dict__.get("tmux_cmd", "") + and record.getMessage() == "tmux command dispatched" + ] + assert [command[-1] for command in respawn_commands] == [respawn_shell] + finally: + for pane_id in (pane_branch.pane_id, window_branch.pane_id): + pane = mcp_server.panes.get(pane_id=pane_id, default=None) + if pane is not None: + pane.kill() From aefabcd0690df269109ffadafba2be9d4582c019 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 20:19:25 -0500 Subject: [PATCH 03/20] mcp(fix[spawn]): Clarify history policy why: MCP clients need startup precedence in tool schemas and accurate, value-free guidance when a history setting conflicts. what: - Explain MCP omission, explicit override, and Python defaults - Give shell-specific guidance without reflecting supplied values - Cover transformed schemas and every conflict surface --- src/libtmux_mcp/_history.py | 7 ++- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 5 +- src/libtmux_mcp/tools/server_tools.py | 5 +- src/libtmux_mcp/tools/session_tools.py | 5 +- src/libtmux_mcp/tools/window_tools.py | 5 +- tests/test_history.py | 50 +++++++++++++++++-- tests/test_spawn_tools_history.py | 8 +-- 7 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/libtmux_mcp/_history.py b/src/libtmux_mcp/_history.py index 8a81d104..5bff396a 100644 --- a/src/libtmux_mcp/_history.py +++ b/src/libtmux_mcp/_history.py @@ -107,11 +107,16 @@ def _prepare_spawn_environment( "fish_history": "", "fish_private_mode": "1", } + corrections = { + "HISTFILE": "omit it or set it to an empty string", + "fish_history": "omit it or set it to an empty string", + "fish_private_mode": "omit it or set it to '1'", + } for name, required in required_values.items(): if name in result and result[name] != required: msg = ( f"environment variable {name} conflicts with " - "suppress_history=True; omit it or use the required empty value" + f"suppress_history=True; {corrections[name]}" ) raise ExpectedToolError(msg) result[name] = required diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 8c434c6a..45133ed5 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -133,8 +133,9 @@ def respawn_pane( socket_name : str, optional tmux socket name. suppress_history : bool - Request best-effort shell-history suppression for the relaunched - process. Direct Python calls default to False. + For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY + default; an explicit value overrides it. Direct Python calls default + to False. Startup files may override these controls. Returns ------- diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index 5f9936d0..9bd173ea 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -104,8 +104,9 @@ def create_session( socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. suppress_history : bool - Request best-effort shell-history suppression for the initial shell - and future panes. Direct Python calls default to False. + For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY + default; an explicit value overrides it. Direct Python calls default + to False. Startup files may override these controls. Returns ------- diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index dad44f8f..39bc711c 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -146,8 +146,9 @@ def create_window( Per-process environment as a mapping or JSON object string. Values do not modify the tmux session environment. suppress_history : bool - Request best-effort shell-history suppression for the spawned shell. - Direct Python calls default to False. + For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY + default; an explicit value overrides it. Direct Python calls default + to False. Startup files may override these controls. Returns ------- diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 4abe9ff3..9d7c15b3 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -199,8 +199,9 @@ def split_window( Per-process environment as a mapping or JSON object string. Values do not modify the tmux session environment. suppress_history : bool - Request best-effort shell-history suppression for the spawned shell. - Direct Python calls default to False. + For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY + default; an explicit value overrides it. Direct Python calls default + to False. Startup files may override these controls. Returns ------- diff --git a/tests/test_history.py b/tests/test_history.py index 6b25be6b..890af949 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -50,6 +50,12 @@ class McpHistoryBehaviorFixture(t.NamedTuple): McpHistoryBehaviorFixture("startup_enabled", "1", False), ] +SPAWN_SUPPRESS_HISTORY_DESCRIPTION = ( + "For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY " + "default; an explicit value overrides it. Direct Python calls default to " + "False. Startup files may override these controls." +) + def _history_server(value: str) -> FastMCP: """Build a focused MCP server with one resolved history transform.""" @@ -146,6 +152,9 @@ async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: assert schema["type"] == "boolean" assert schema["default"] is default assert "anyOf" not in schema + if name != "run_command": + description = " ".join(schema["description"].split()) + assert description == SPAWN_SUPPRESS_HISTORY_DESCRIPTION class SpawnEnvironmentFixture(t.NamedTuple): @@ -236,16 +245,29 @@ def test_prepare_spawn_environment_normalizes_copies_and_merges( @pytest.mark.parametrize( - ("name", "supplied"), + ("name", "supplied", "correction"), [ - ("HISTFILE", "private-bash-history-path"), - ("fish_history", "private-fish-history-name"), - ("fish_private_mode", "private-fish-mode-value"), + ( + "HISTFILE", + "private-bash-history-path", + "omit it or set it to an empty string", + ), + ( + "fish_history", + "private-fish-history-name", + "omit it or set it to an empty string", + ), + ( + "fish_private_mode", + "private-fish-mode-value", + "omit it or set it to '1'", + ), ], ) def test_prepare_spawn_environment_rejects_conflicts_without_values( name: str, supplied: str, + correction: str, ) -> None: """Policy conflicts identify only the variable, never its supplied value.""" from fastmcp.exceptions import ToolError @@ -256,7 +278,7 @@ def test_prepare_spawn_environment_rejects_conflicts_without_values( original = environment.copy() expected = ( f"environment variable {name} conflicts with suppress_history=True; " - "omit it or use the required empty value" + f"{correction}" ) with pytest.raises(ToolError) as excinfo: @@ -332,6 +354,16 @@ async def main(): mode="json", exclude_none=True ), "tags": tool.meta["fastmcp"]["tags"], + "spawn_descriptions": { + name: tools[name].inputSchema["properties"] + ["suppress_history"]["description"] + for name in ( + "create_session", + "create_window", + "split_window", + "respawn_pane", + ) + }, }, sort_keys=True)) asyncio.run(main()) @@ -365,6 +397,14 @@ async def main(): "readOnlyHint": False, } assert payload["tags"] == ["mutating"] + expected_descriptions = dict.fromkeys( + ("create_session", "create_window", "split_window", "respawn_pane"), + SPAWN_SUPPRESS_HISTORY_DESCRIPTION, + ) + assert { + name: " ".join(description.split()) + for name, description in payload["spawn_descriptions"].items() + } == expected_descriptions def test_invalid_history_setting_fails_server_startup_without_echoing_value() -> None: diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py index 7c89102c..7028fc9e 100644 --- a/tests/test_spawn_tools_history.py +++ b/tests/test_spawn_tools_history.py @@ -130,7 +130,7 @@ def _assert_value_free_spawn_conflict(call: t.Callable[[], t.Any], name: str) -> assert str(excinfo.value) == ( f"environment variable {name} conflicts with suppress_history=True; " - "omit it or use the required empty value" + "omit it or set it to an empty string" ) assert supplied not in str(excinfo.value) @@ -281,7 +281,7 @@ async def _exercise() -> None: error = inherited.content[0].text assert error == ( "environment variable HISTFILE conflicts with " - "suppress_history=True; omit it or use the required empty value" + "suppress_history=True; omit it or set it to an empty string" ) assert supplied not in error assert {window.window_id for window in mcp_session.windows} == before @@ -371,7 +371,7 @@ async def _exercise() -> t.Any: assert failed["success"] is False assert failed["error"] == ( "environment variable HISTFILE conflicts with suppress_history=True; " - "omit it or use the required empty value" + "omit it or set it to an empty string" ) assert supplied not in failed["error"] assert succeeded["success"] is True @@ -413,7 +413,7 @@ async def _exercise() -> t.Any: assert result.content assert result.content[0].text == ( "environment variable HISTFILE conflicts with suppress_history=True; " - "omit it or use the required empty value" + "omit it or set it to an empty string" ) assert supplied not in repr(result) assert supplied not in "\n".join(record.getMessage() for record in caplog.records) From 828d9946c10bcc4a37ab42f5a3fadf6b4a7f7b9c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 21:00:15 -0500 Subject: [PATCH 04/20] mcp(docs[history]): Document suppression why: Give operators an accurate user-facing contract for best-effort history controls and the visibility surfaces they do not hide. what: - Document startup precedence, eligible tools, shell caveats, and scope - Add safety and logging boundaries for remaining visibility - Lock stable documentation claims with focused contract tests --- docs/configuration.md | 20 +++- docs/tools/pane/respawn-pane.md | 4 + docs/tools/pane/run-command.md | 9 +- docs/tools/server/create-session.md | 4 + docs/tools/session/create-window.md | 4 + docs/tools/window/split-window.md | 4 + docs/topics/gotchas.md | 24 +++- docs/topics/logging.md | 10 +- docs/topics/safety.md | 19 ++- tests/docs/test_topic_contracts.py | 172 ++++++++++++++++++++++++++++ 10 files changed, 246 insertions(+), 24 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f94108ca..42dd27ff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,23 @@ Safety tier controlling which tools are available. See {ref}`safety`. - **Default:** `mutating` - **Values:** `readonly`, `mutating`, `destructive` +```{envvar} LIBTMUX_SUPPRESS_HISTORY +``` + +Controls the MCP default for best-effort shell-history suppression. This setting applies when an MCP caller omits `suppress_history` from {tooliconl}`run-command`, {tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, or {tooliconl}`respawn-pane`. + +- **Type:** string flag +- **Default:** `0` (disabled) +- **Values:** `0`, `1` + +Unset and `0` disable suppression; `1` enables it. Any other value fails server startup with `LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'`, without echoing the rejected value. Precedence is per call: an explicit `suppress_history` value wins, then {envvar}`LIBTMUX_SUPPRESS_HISTORY`, then the default `False`. Direct Python calls also default to `False`. + +The tools apply that value in two different ways. {toolref}`run-command` prefixes one space to the complete grouped command event submitted to the existing shell. Each of the four spawn tools copies and merges the caller's environment with shell-history controls before starting a process. A conflicting caller-supplied history value fails the call, names the environment variable without including the conflicting value, and is never retried without suppression. + +An explicit `suppress_history=false` stops the tool from adding a prefix or new environment controls for that call. It does not remove controls that the target process already inherits from a session, parent environment, or shell startup file. The startup default never changes the raw-input behavior of {toolref}`send-keys`, {toolref}`send-keys-batch`, {toolref}`paste-text`, or {toolref}`paste-buffer`. + +The server resolves this setting once during startup. After changing it, restart the MCP server, usually by reconnecting or restarting the MCP client. See {ref}`history-hygiene` for the shell-specific limits and {ref}`safety` for surfaces that history suppression does not hide. + ## Setting environment variables Set environment variables in your MCP client config: @@ -51,7 +68,8 @@ Set environment variables in your MCP client config: "args": ["libtmux-mcp"], "env": { "LIBTMUX_SOCKET": "ai_workspace", - "LIBTMUX_SAFETY": "readonly" + "LIBTMUX_SAFETY": "readonly", + "LIBTMUX_SUPPRESS_HISTORY": "1" } } } diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 0a063ce2..104cc222 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -19,6 +19,10 @@ the layout: `respawn-pane` preserves the pane in place. starts a new one. **The `pane_id` is preserved** — that's the whole point of the tool. `pane_pid` updates to the new process. +For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`respawn-pane` adds an environment that applies to only the spawned process; it does not change the tmux session environment or affect later panes. An explicit `false` prevents new controls but does not remove controls inherited by the pane process. Shell startup files can override the controls; see {ref}`history-hygiene` and {ref}`safety`. + +The history policy only copies and merges environment values; it does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. + **Tip:** Call {tooliconl}`get-pane-info` first if you need to capture `pane_current_command` before respawn — the new process loses its argv. Omitting `shell` makes tmux replay the original argv (good default for diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md index 7cb4ffe2..6da1c512 100644 --- a/docs/tools/pane/run-command.md +++ b/docs/tools/pane/run-command.md @@ -10,12 +10,9 @@ result with exit status, timeout state, and captured pane output. {tooliconl}`send-keys` or {tooliconl}`send-keys-batch` for TUIs, key names, and partial commands. -**Side effects:** Sends a command to the pane's interactive shell. The -command may read or write files, start processes, or access the network -depending on what the shell command does. Each command runs in a subshell, -so directory or environment changes do not persist across calls. -Set `suppress_history=true` for secret-bearing commands on shells that -honor leading-space history suppression. +**Side effects:** Sends a command to the pane's interactive shell. The command may read or write files, start processes, or access the network depending on what the shell command does. Each command runs in a subshell, so directory or environment changes do not persist across calls. + +For MCP calls, the {ref}`configuration ` setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` supplies the value when this argument is omitted, and an explicit `suppress_history` value wins. Direct Python calls default to `False`. Suppression is best effort: {tooliconl}`run-command` prefixes the complete grouped history event with one space, but the existing shell must be configured to ignore space-prefixed commands. An explicit `false` skips the prefix; it does not change or undo that shell's history environment or startup configuration. Do not use history suppression as secret transport. See {ref}`history-hygiene` for shell behavior and {ref}`safety` before handling credentials. **Example:** diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index 3288794a..101a8c3d 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -11,6 +11,10 @@ container — create one before creating windows or panes. **Side effects:** Creates a new tmux session with one window and one pane. +For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`create-session` copies and merges supported shell-history controls into the tmux session environment, so they reach the initial pane and future panes in that session. An explicit `false` prevents new controls but does not remove compatible values already supplied in `environment`. Shell startup files can override the controls, and suppression does not remove terminal output or other traces; see {ref}`history-hygiene` and {ref}`safety`. + +The history policy only copies and merges environment values; it does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. + **Example:** ```json diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md index b3954532..09396b88 100644 --- a/docs/tools/session/create-window.md +++ b/docs/tools/session/create-window.md @@ -7,6 +7,10 @@ **Side effects:** Creates a new window. Attaches to it if `attach` is true. +For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`create-window` adds an environment that applies to only the spawned process; it does not change the tmux session environment, and future windows or panes do not inherit it. An explicit `false` prevents new controls but does not remove controls inherited from the session environment. Shell startup files can override the controls; see {ref}`history-hygiene` and {ref}`safety`. + +The history policy only copies and merges environment values; it does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. + **Example:** ```json diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md index a457324c..91ecd1a7 100644 --- a/docs/tools/window/split-window.md +++ b/docs/tools/window/split-window.md @@ -8,6 +8,10 @@ window. **Side effects:** Creates a new pane by splitting an existing one. +For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`split-window` adds an environment that applies to only the spawned process; it does not change the tmux session environment, and later panes do not inherit it. An explicit `false` prevents new controls but does not remove controls inherited from the session environment. Shell startup files can override the controls; see {ref}`history-hygiene` and {ref}`safety`. + +The history policy only copies and merges environment values; it does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. + **Example:** ```json diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 4ac88f4a..4223f2dc 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -74,13 +74,25 @@ Pane IDs like `%0`, `%5`, `%12` are unique across all sessions and windows withi However, they reset when the tmux **server** restarts. Do not cache pane IDs across server restarts. After killing and recreating a session, re-discover pane IDs with {tooliconl}`list-panes`. -## `suppress_history` requires shell support +(history-hygiene)= -The `suppress_history` parameter on {tooliconl}`send-keys` and -{tooliconl}`run-command` prepends a space before the command, which prevents it -from being saved in shell history. This only works if the shell's `HISTCONTROL` -variable includes `ignorespace` (the default for bash, but not universal across -all shells). +## Shell-history suppression is best effort + +The `suppress_history` argument reduces one history-persistence path; it does not make terminal input secret. {tooliconl}`run-command` prepends one ASCII space to its complete grouped history event. For [Bash 5.3](https://git.savannah.gnu.org/gitweb/?p=bash.git;a=blob;f=doc/bashref.texi;hb=b8c60bc), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. + +[Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) requires `HIST_IGNORE_SPACE` for the same prefix convention, and an ignored event can linger in internal history until the next event. [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) normally keeps a leading-space command off disk but leaves it recallable until the next command. A custom [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) function takes over that decision and can store leading-space commands, while [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) can strip leading spaces from pasted text. Do not treat the prefix convention as protection for paste or multiline input. + +The startup setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` also supplies the omitted default for {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane`. Those spawn tools copy and merge an empty `HISTFILE`, Bash's `HISTCONTROL`, and private-history variables into the new process environment. The result is deliberately described as best effort: + +| Shell | Spawn controls | Remaining caveat | +|-------|----------------|------------------| +| Bash | Empty `HISTFILE`; add `ignorespace` unless `ignoreboth` is already present | The interactive process can retain in-memory history. | +| Zsh | Empty `HISTFILE` | The interactive process can retain in-memory history. | +| Fish | Empty `fish_history`; non-empty `fish_private_mode` | The interactive process can retain in-memory history. | + +Startup files can override any of these environment values after the process begins, so the shell can resume writing history to disk. An explicit `suppress_history=false` prevents the tool from adding controls, but it cannot erase controls already inherited from a tmux session, parent environment, or startup file. + +{tooliconl}`send-keys` and {tooliconl}`send-keys-batch` do not inherit `LIBTMUX_SUPPRESS_HISTORY`; their `suppress_history` values remain explicit and default to `false`. Enabling it adds one leading space, so do not use it for control keys such as `C-c`, TUI input, or partial text unless that byte is intentional. In a batch, choose suppression separately for each operation. Paste tools have no suppression argument: {toolref}`paste-text` and {toolref}`paste-buffer` add no history prefix, but the active program can still interpret the paste or its whitespace. See {ref}`safety` for pane output, capture, process, transcript, hook, and log surfaces that remain visible. ## Gemini CLI injects `wait_for_previous` into tool arguments diff --git a/docs/topics/logging.md b/docs/topics/logging.md index 59094131..e787d5a8 100644 --- a/docs/topics/logging.md +++ b/docs/topics/logging.md @@ -14,10 +14,7 @@ No manual wiring needed. All loggers are children of ``libtmux_mcp``. The primary streams are: -- ``libtmux_mcp.audit`` — one structured line per tool call, emitted - by {class}`~libtmux_mcp.middleware.AuditMiddleware`. Includes - tool name, digest-redacted arguments, latency, outcome. See - {doc}`/topics/safety` for the argument-redaction rules. +- ``libtmux_mcp.audit`` — one structured line per tool call, emitted by {class}`~libtmux_mcp.middleware.AuditMiddleware`. Includes tool name, digest-redacted arguments, latency, and outcome. See {doc}`/topics/safety` for the argument-redaction rules. It does not record tool return values. - ``libtmux_mcp.retry`` — warnings from {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` when a readonly tool retried after a transient @@ -63,10 +60,7 @@ server name (``libtmux-mcp``), level, and the log message — but not the Python logger name, which the protocol doesn't model. ```{tip} -If a tool call fails silently (no user-visible error, no side -effect), the ``libtmux_mcp.audit`` log will show the invocation and -its return value. That's usually the fastest way to tell whether a -tool ran at all. +If a tool call has no user-visible error or side effect, the ``libtmux_mcp.audit`` log shows the invocation and whether it returned or raised, not the tool's return value. Use the MCP response and current tmux state to determine what the tool returned or changed. ``` ## Further reading diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 7969689f..d359b800 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -98,8 +98,8 @@ Mitigations: Mitigations: -- The audit log redacts the `value` argument to a `{len, sha256_prefix}` digest so log files don't leak the secrets agents set, but operators should still treat the tool as high-privilege. -- If only a single command needs an env override, prefer having the agent invoke `env VAR=value command` via {tooliconl}`send-keys` instead — the blast radius is one command, not every future child. +- The server audit record replaces the `value` argument with a `{len, sha256_prefix}` digest, so the value does not appear verbatim in `libtmux_mcp.audit`. That redaction does not cover separate library, process, application, or client logs, so operators should still treat the tool as high-privilege. +- If only a single command needs a non-sensitive env override, prefer having the agent invoke `env VAR=value command` via {tooliconl}`send-keys` instead — the blast radius is one command, not every future child. For credentials, pass a reference that the child resolves instead of a literal value through tmux. ### Respawning panes @@ -116,7 +116,20 @@ Mitigations: ### Raw pane input -These can execute anything the pane's shell accepts. There is no payload validation. The audit log stores a digest of the content, not the content itself, so a secret typed via {tooliconl}`send-keys` or {tooliconl}`send-keys-batch` does not land in logs. +These can execute anything the pane's shell accepts. There is no payload validation. The server audit log stores a digest of the content, not the content itself, so a secret typed via {tooliconl}`send-keys` or {tooliconl}`send-keys-batch` does not land in that audit record. + +### History suppression is not secret transport + +Best-effort history suppression asks a shell not to persist selected commands in its disk history. It does not hide the command from other observation surfaces: + +- **pane echo and scrollback:** the terminal can display input, tmux can retain it in pane history, and an attached terminal can keep its own scrollback. +- **capture tools and piping:** {tooliconl}`capture-pane`, {tooliconl}`capture-since`, {tooliconl}`snapshot-pane`, {tooliconl}`search-panes`, and {tooliconl}`pipe-pane` can return or route displayed and retained text. +- **hooks:** configured tmux hooks, including state visible through {tooliconl}`show-hooks`, and shell instrumentation can observe process or pane activity independently of shell history. +- **process visibility:** command arguments, launch strings, and environment values may be visible to host process inspection while a process starts or runs. +- **MCP client transcripts:** clients can retain the original request and response outside the server's control. +- **logs:** `libtmux_mcp.audit` records redacted arguments and whether the call succeeded or raised; it does not contain tool return values. Redaction applies only to these audit records and does not rewrite separate records emitted by libtmux, FastMCP, shells, or MCP clients. libtmux DEBUG or error records may contain shell-joined tmux arguments, while MCP client request logs and application logs remain outside the server's guarantee. + +Prefer credential references that a process resolves from a secret manager, scoped file descriptor, or preconfigured host lookup. Avoid literal credentials in `command`, raw `keys` or `text`, `shell`, and `environment` arguments; history suppression cannot retract a value after another surface records it. ## Audit log diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 2a7cee6d..476b77f4 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -46,3 +46,175 @@ def test_topic_docs_do_not_overclaim_runtime_features( text = (docs_dir / relative_path).read_text(encoding="utf-8") assert forbidden_text not in text + + +def test_configuration_documents_history_default_precedence_and_restart( + docs_dir: pathlib.Path, +) -> None: + """History configuration names its scope, precedence, and lifecycle.""" + text = (docs_dir / "configuration.md").read_text(encoding="utf-8") + + assert "```{envvar} LIBTMUX_SUPPRESS_HISTORY" in text + assert "**Default:** `0`" in text + assert "Unset and `0` disable suppression; `1` enables it" in text + assert "Any other value fails server startup" in text + assert "LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'" in text + assert "explicit `suppress_history` value wins" in text + assert "restart the MCP server" in text + assert "prefixes one space" in text + assert "copies and merges" in text + assert "does not remove controls that the target process already inherits" in text + assert "without including the conflicting value" in text + for tool in ( + "run-command", + "create-session", + "create-window", + "split-window", + "respawn-pane", + ): + assert f"{{tooliconl}}`{tool}`" in text + for tool in ("send-keys", "send-keys-batch", "paste-text", "paste-buffer"): + assert f"{{toolref}}`{tool}`" in text + + +def test_history_gotcha_documents_shell_limits_and_raw_input_boundary( + docs_dir: pathlib.Path, +) -> None: + """History guidance stays best effort and corrects the Bash default claim.""" + text = (docs_dir / "topics" / "gotchas.md").read_text(encoding="utf-8") + + assert "## Shell-history suppression is best effort" in text + assert "`ignorespace` is not a Bash default" in text + assert "(history-hygiene)=" in text + assert "Startup files can override" in text + assert "in-memory history" in text + assert "HIST_IGNORE_SPACE" in text + assert "fish_should_add_to_history" in text + assert "bracketed paste" in text + assert "recallable until the next command" in text + assert "{tooliconl}`send-keys-batch`" in text + assert "do not inherit `LIBTMUX_SUPPRESS_HISTORY`" in text + assert "control keys such as `C-c`, TUI input, or partial text" in text + assert "Paste tools have no suppression argument" in text + assert "the default for bash" not in text + + +@pytest.mark.parametrize( + ("relative_path", "required_scope", "tool_slug"), + ( + ( + "tools/server/create-session.md", + "future panes in that session", + "create-session", + ), + ( + "tools/session/create-window.md", + "only the spawned process", + "create-window", + ), + ( + "tools/window/split-window.md", + "only the spawned process", + "split-window", + ), + ( + "tools/pane/respawn-pane.md", + "only the spawned process", + "respawn-pane", + ), + ), +) +def test_spawn_tool_pages_document_history_environment_scope( + docs_dir: pathlib.Path, + relative_path: str, + required_scope: str, + tool_slug: str, +) -> None: + """Each spawn page says how far its history environment propagates.""" + text = (docs_dir / relative_path).read_text(encoding="utf-8") + + assert "`suppress_history`" in text + assert required_scope in text + assert "startup files" in text + assert "Direct Python calls default to `False`" in text + assert "does not rewrite command text" in text + assert f"{{tooliconl}}`{tool_slug}`" in text + + +def test_run_command_page_documents_effective_history_policy( + docs_dir: pathlib.Path, +) -> None: + """The semantic command page distinguishes startup and explicit policy.""" + text = (docs_dir / "tools" / "pane" / "run-command.md").read_text(encoding="utf-8") + + assert "`LIBTMUX_SUPPRESS_HISTORY`" in text + assert "explicit `suppress_history` value wins" in text + assert "Direct Python calls default to `False`" in text + assert "existing shell" in text + assert "best effort" in text + + +def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( + docs_dir: pathlib.Path, +) -> None: + """Safety guidance does not present history suppression as secret transport.""" + text = (docs_dir / "topics" / "safety.md").read_text(encoding="utf-8") + + for surface in ( + "pane echo", + "scrollback", + "capture tools", + "hooks", + "process visibility", + "MCP client transcripts", + "logs", + ): + assert surface in text + assert "credential references" in text + assert "literal credentials" in text + assert "{tooliconl}`pipe-pane`" in text + assert "attached terminal" in text + assert "application logs" in text + assert "shell-joined tmux arguments" in text + assert "does not appear verbatim in `libtmux_mcp.audit`" in text + assert "does not contain tool return values" in text + assert "Redaction applies only to these audit records" in text + assert "libtmux, FastMCP, shells, or MCP clients" in text + + +def test_logging_docs_describe_audit_outcomes_without_return_values( + docs_dir: pathlib.Path, +) -> None: + """Audit guidance distinguishes call outcome from returned tool data.""" + text = (docs_dir / "topics" / "logging.md").read_text(encoding="utf-8") + + assert ( + "the ``libtmux_mcp.audit`` log shows the invocation and whether it " + "returned or raised, not the tool's return value." + ) in text + + +def test_unreleased_changelog_documents_history_suppression( + docs_dir: pathlib.Path, +) -> None: + """The unreleased changelog exposes the history-control deliverable.""" + text = (docs_dir.parent / "CHANGES").read_text(encoding="utf-8") + placeholder_end = ( + "" + ) + after_placeholder = text.split(placeholder_end, maxsplit=1)[1] + after_placeholder_lines = after_placeholder.splitlines() + next_release_index = next( + index + for index, line in enumerate(after_placeholder_lines) + if line.startswith("## libtmux-mcp ") + ) + unreleased = "\n".join(after_placeholder_lines[:next_release_index]) + + assert "**Best-effort shell-history suppression**" in unreleased + assert "{tooliconl}`run-command`" in unreleased + assert "{tooliconl}`create-session`" in unreleased + assert "{envvar}`LIBTMUX_SUPPRESS_HISTORY`" in unreleased + assert "{toolref}`send-keys-batch`" in unreleased + assert "{toolref}`paste-text`" in unreleased + assert "{ref}`configuration`" in unreleased From 28df6b4f0d380aa0699cf4dc6c4b346e7f9dca61 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 21:25:19 -0500 Subject: [PATCH 05/20] mcp(fix[history]): Describe spawn defaults why: Server instructions named only run_command even though semantic spawn commands inherit the active shell-history default. what: - Name run_command and spawn inheritance in the active-default sentence - Preserve raw input exclusions and UTF-8 instruction-budget guards --- src/libtmux_mcp/server.py | 4 ++-- tests/test_server.py | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index c72a4314..38653545 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -169,8 +169,8 @@ def _build_instructions( ) history_default = "true" if suppress_history else "false" parts.append( - f"\n\nHistory: run_command suppression defaults {history_default}. " - "Raw send_keys/send_keys_batch/paste tools never inherit." + f"\n\nsuppress_history={history_default}: run_command/spawn inherit; " + "raw send/batch/paste do not." ) # Tier-conditioned discoverability hint. False-positive activation is diff --git a/tests/test_server.py b/tests/test_server.py index e310a027..daad3176 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -414,8 +414,8 @@ def test_build_instructions_documents_semantic_history_default_and_raw_boundary( instructions = _build_instructions(suppress_history=suppress_history) assert ( - f"History: run_command suppression defaults {expected_default}. " - "Raw send_keys/send_keys_batch/paste tools never inherit." + f"suppress_history={expected_default}: run_command/spawn inherit; " + "raw send/batch/paste do not." ) in instructions @@ -505,8 +505,7 @@ def test_instruction_budget_drops_oversized_socket_before_required_text( assert len(instructions.encode("utf-8")) <= 2048 assert _BASE_INSTRUCTIONS in instructions assert ( - "History: run_command suppression defaults true. " - "Raw send_keys/send_keys_batch/paste tools never inherit." + "suppress_history=true: run_command/spawn inherit; raw send/batch/paste do not." ) in instructions assert "Agent context" in instructions assert "%42" in instructions @@ -529,8 +528,8 @@ def test_instruction_budget_can_drop_all_oversized_optional_context( assert len(instructions.encode("utf-8")) <= 2048 assert _BASE_INSTRUCTIONS in instructions assert ( - "History: run_command suppression defaults false. " - "Raw send_keys/send_keys_batch/paste tools never inherit." + "suppress_history=false: run_command/spawn inherit; " + "raw send/batch/paste do not." ) in instructions assert "Agent context" not in instructions From 34c5b33e717f8328992f025d1605d5def6554509 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 21:52:26 -0500 Subject: [PATCH 06/20] mcp(test[spawn]): Reuse pane in scope test why: Keep the process-environment coverage compatible with tmux 3.2a's minimum pane size. what: - Reuse the verified later pane for the respawn assertions - Avoid creating a fifth pane in the fixture window --- tests/test_spawn_tools_history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py index 7028fc9e..131a2f0a 100644 --- a/tests/test_spawn_tools_history.py +++ b/tests/test_spawn_tools_history.py @@ -566,7 +566,7 @@ def test_spawn_tools_forward_process_environment_without_session_leakage( raises=True, ) - respawn_target = mcp_pane.window.split(shell="sleep 30") + respawn_target = later_pane assert respawn_target.pane_id is not None try: respawn_pane( From 04799bd4349fa057d79f4309d6cb17824679a794 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 21:53:50 -0500 Subject: [PATCH 07/20] mcp(docs[audit]): Clarify env redaction why: Describe both accepted respawn environment representations and their distinct audit summaries. what: - Explain per-key redaction for mapping input - Explain scalar redaction for JSON object strings --- docs/topics/safety.md | 2 +- src/libtmux_mcp/middleware.py | 9 ++++----- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 12 +++++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/topics/safety.md b/docs/topics/safety.md index d359b800..2b922897 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -111,7 +111,7 @@ Mitigations: - `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of an unintended kill — resolve via {tool}`list-panes` first. - Any `shell` argument is briefly visible in the OS process table and tmux's `pane_current_command` metadata before the spawned shell takes over; the audit log redacts `shell` payloads (see below), but do not pass credentials directly even with redaction. -- The optional `environment` argument (`dict[str, str]`) maps to one tmux `-e KEY=VALUE` flag per item. The audit log redacts each *value* via a `{len, sha256_prefix}` digest while keeping the *keys* visible — env var names like `DATABASE_URL` are usually operator-debug-useful, but their values are the secret. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env. +- The optional `environment` argument accepts either a mapping of string keys and values or a JSON object string, then maps each item to one tmux `-e KEY=VALUE` flag. For a mapping, the audit log keeps each *key* visible and replaces each *value* with a `{len, sha256_prefix}` digest. A JSON string is redacted as one scalar digest, so its keys are not retained in the audit record. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env. - The same self-pane guard that protects the destructive kill commands also refuses to respawn the pane running the MCP server. ### Raw pane input diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 1e064524..d66567b8 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -440,11 +440,10 @@ async def on_call_tool( #: secrets, or arbitrary large strings. #: Matched by exact name, case-sensitive, to mirror the tool signatures. #: -#: ``environment`` is dict-shaped (``dict[str, str]``); the redaction logic -#: in :func:`_summarize_args` recognises this and digests each *value* while -#: leaving the *keys* (env var names like ``DATABASE_URL``) visible — env -#: var names are operator-debug-useful, but their values are the secret. -#: All other entries are scalar strings; mixing the two is intentional. +#: ``environment`` accepts a mapping or a JSON object string. The redaction +#: logic in :func:`_summarize_args` digests each mapping *value* while leaving +#: its *keys* (env var names like ``DATABASE_URL``) visible. A JSON string is +#: instead redacted as one scalar digest, so its keys are not retained. #: #: Note on ``shell`` and ``environment`` redaction: this redacts the MCP #: audit log only. ``respawn_pane(shell="env SECRET=... bash")`` and diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 45133ed5..5dc797d1 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -124,11 +124,13 @@ def respawn_pane( Environment variables to set for the relaunched process. Each item becomes one ``-e KEY=VALUE`` flag (tmux's ``cmd-respawn-pane.c`` supports the flag repeatedly). Values - are redacted in the audit log on a per-key basis — keys like - ``DATABASE_URL`` remain visible but their values are replaced - by ``{len, sha256_prefix}`` digests. Note that the values may - still appear briefly in the OS process table while tmux spawns - the new process; do not pass long-lived secrets here when a + supplied in a mapping are redacted in the audit log on a + per-key basis — keys like ``DATABASE_URL`` remain visible but + their values are replaced by ``{len, sha256_prefix}`` digests. + A JSON object string is redacted as one scalar digest, so its + keys are not retained in the audit record. Values may still + appear briefly in the OS process table while tmux spawns the + new process; do not pass long-lived secrets here when a host-resident agent or other tenant could observe ``ps``. socket_name : str, optional tmux socket name. From 68f98dacabfc18d759e556e327b6f80fb06150de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 21:59:50 -0500 Subject: [PATCH 08/20] mcp(fix[history]): Reject multiline input why: Prevent caller line breaks from escaping the grouped history event when suppression is enabled. what: - Reject carriage returns and line feeds before tmux resolution - Preserve explicit suppression-off multiline behavior - Document and test the narrowed contract --- docs/configuration.md | 2 +- docs/tools/pane/run-command.md | 2 +- docs/topics/gotchas.md | 4 +- src/libtmux_mcp/tools/pane_tools/io.py | 6 +- tests/test_history.py | 125 +++++++++++-------------- 5 files changed, 64 insertions(+), 75 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 42dd27ff..75fb15aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,7 +50,7 @@ Controls the MCP default for best-effort shell-history suppression. This setting Unset and `0` disable suppression; `1` enables it. Any other value fails server startup with `LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'`, without echoing the rejected value. Precedence is per call: an explicit `suppress_history` value wins, then {envvar}`LIBTMUX_SUPPRESS_HISTORY`, then the default `False`. Direct Python calls also default to `False`. -The tools apply that value in two different ways. {toolref}`run-command` prefixes one space to the complete grouped command event submitted to the existing shell. Each of the four spawn tools copies and merges the caller's environment with shell-history controls before starting a process. A conflicting caller-supplied history value fails the call, names the environment variable without including the conflicting value, and is never retried without suppression. +The tools apply that value in two different ways. {toolref}`run-command` prefixes one space to the grouped event that carries the caller's single-line command. When suppression is effective, a command containing a carriage return or line feed fails before tmux receives input; set `suppress_history=false` for intentional multiline input. Each of the four spawn tools copies and merges the caller's environment with shell-history controls before starting a process. A conflicting caller-supplied history value fails the call, names the environment variable without including the conflicting value, and is never retried without suppression. An explicit `suppress_history=false` stops the tool from adding a prefix or new environment controls for that call. It does not remove controls that the target process already inherits from a session, parent environment, or shell startup file. The startup default never changes the raw-input behavior of {toolref}`send-keys`, {toolref}`send-keys-batch`, {toolref}`paste-text`, or {toolref}`paste-buffer`. diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md index 6da1c512..f39883be 100644 --- a/docs/tools/pane/run-command.md +++ b/docs/tools/pane/run-command.md @@ -12,7 +12,7 @@ names, and partial commands. **Side effects:** Sends a command to the pane's interactive shell. The command may read or write files, start processes, or access the network depending on what the shell command does. Each command runs in a subshell, so directory or environment changes do not persist across calls. -For MCP calls, the {ref}`configuration ` setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` supplies the value when this argument is omitted, and an explicit `suppress_history` value wins. Direct Python calls default to `False`. Suppression is best effort: {tooliconl}`run-command` prefixes the complete grouped history event with one space, but the existing shell must be configured to ignore space-prefixed commands. An explicit `false` skips the prefix; it does not change or undo that shell's history environment or startup configuration. Do not use history suppression as secret transport. See {ref}`history-hygiene` for shell behavior and {ref}`safety` before handling credentials. +For MCP calls, the {ref}`configuration ` setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` supplies the value when this argument is omitted, and an explicit `suppress_history` value wins. Direct Python calls default to `False`. Suppression is best effort: {tooliconl}`run-command` prefixes one space to the grouped event that carries the caller's single-line command, but the existing shell must be configured to ignore space-prefixed commands. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input because a prefix cannot protect each shell event. Set `suppress_history=false` to keep intentional multiline behavior. An explicit `false` does not change or undo that shell's history environment or startup configuration. Do not use history suppression as secret transport. See {ref}`history-hygiene` for shell behavior and {ref}`safety` before handling credentials. **Example:** diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 4223f2dc..889325f1 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -78,9 +78,9 @@ However, they reset when the tmux **server** restarts. Do not cache pane IDs acr ## Shell-history suppression is best effort -The `suppress_history` argument reduces one history-persistence path; it does not make terminal input secret. {tooliconl}`run-command` prepends one ASCII space to its complete grouped history event. For [Bash 5.3](https://git.savannah.gnu.org/gitweb/?p=bash.git;a=blob;f=doc/bashref.texi;hb=b8c60bc), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. +The `suppress_history` argument reduces one history-persistence path; it does not make terminal input secret. {tooliconl}`run-command` prepends one ASCII space to the grouped event that carries the caller's single-line command. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input; set `suppress_history=false` for intentional multiline input. For [Bash 5.3](https://git.savannah.gnu.org/gitweb/?p=bash.git;a=blob;f=doc/bashref.texi;hb=b8c60bc), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. -[Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) requires `HIST_IGNORE_SPACE` for the same prefix convention, and an ignored event can linger in internal history until the next event. [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) normally keeps a leading-space command off disk but leaves it recallable until the next command. A custom [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) function takes over that decision and can store leading-space commands, while [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) can strip leading spaces from pasted text. Do not treat the prefix convention as protection for paste or multiline input. +[Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) requires `HIST_IGNORE_SPACE` for the same prefix convention, and an ignored event can linger in internal history until the next event. [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) normally keeps a leading-space command off disk but leaves it recallable until the next command. A custom [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) function takes over that decision and can store leading-space commands, while [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) can strip leading spaces from pasted text. Do not treat the prefix convention as protection for paste input. The startup setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` also supplies the omitted default for {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane`. Those spawn tools copy and merge an empty `HISTFILE`, Bash's `HISTCONTROL`, and private-history variables into the new process environment. The result is deliberately described as best effort: diff --git a/src/libtmux_mcp/tools/pane_tools/io.py b/src/libtmux_mcp/tools/pane_tools/io.py index 90ba50b6..54caecc2 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -365,7 +365,8 @@ async def run_command( For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY default; an explicit value overrides it. Direct Python calls default to False. Best effort: the shell must honor space-prefixed history - suppression. + suppression. Suppression requires a single-line command; multiline + commands remain available when suppression is false. socket_name : str, optional tmux socket name. @@ -378,6 +379,9 @@ async def run_command( if not command.strip(): msg = "command must not be empty" raise ExpectedToolError(msg) + if suppress_history and ("\n" in command or "\r" in command): + msg = "command must be a single line when suppress_history=True" + raise ExpectedToolError(msg) if timeout <= 0: msg = "timeout must be positive" raise ExpectedToolError(msg) diff --git a/tests/test_history.py b/tests/test_history.py index 890af949..33198a00 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -432,7 +432,8 @@ def test_run_command_describes_mcp_precedence_and_direct_python_default() -> Non "For MCP calls, omission uses the server's " "LIBTMUX_SUPPRESS_HISTORY default; an explicit value overrides it. " "Direct Python calls default to False. Best effort: the shell must honor " - "space-prefixed history suppression." + "space-prefixed history suppression. Suppression requires a single-line " + "command; multiline commands remain available when suppression is false." ) parameter = inspect.signature(pane_tools.run_command).parameters["suppress_history"] mcp = FastMCP("run-command-description") @@ -888,89 +889,73 @@ async def _exercise() -> None: assert paste_calls[1] == (existing_buffer, True, False) -def test_mcp_run_command_protects_complete_multiline_bash_history_event( +@pytest.mark.parametrize( + "line_break", + [pytest.param("\n", id="line-feed"), pytest.param("\r", id="carriage-return")], +) +def test_mcp_run_command_rejects_multiline_suppression_before_tmux( + monkeypatch: pytest.MonkeyPatch, + line_break: str, +) -> None: + """The enabled omitted default rejects breakouts before tmux resolution.""" + from libtmux_mcp.tools.pane_tools import io + + private_marker = "PRIVATE_MULTILINE_MARKER" + command = f"){line_break}printf '%s' {private_marker}{line_break}(" + + def _unexpected_get_server(**_kwargs: t.Any) -> t.NoReturn: + msg = "multiline validation must precede _get_server" + raise AssertionError(msg) + + monkeypatch.setattr(io, "_get_server", _unexpected_get_server) + + async def _exercise() -> t.Any: + async with Client(_history_server("1")) as client: + return await client.call_tool( + "run_command", + {"command": command}, + raise_on_error=False, + ) + + result = asyncio.run(_exercise()) + + assert result.is_error is True + assert result.content + assert result.content[0].text == ( + "command must be a single line when suppress_history=True" + ) + assert private_marker not in repr(result) + + +def test_mcp_run_command_preserves_multiline_when_suppression_disabled( mcp_server: Server, mcp_pane: Pane, tmp_path: pathlib.Path, ) -> None: - """One grouped multiline command executes fully without disk history.""" - histfile = tmp_path / "multiline.bash_history" - mcp_pane.send_keys("exec bash --noprofile --norc", enter=True) - retry_until( - lambda: any("bash-" in line for line in mcp_pane.capture_pane()), - 2, - raises=True, - ) - setup = ( - f"HISTFILE={shlex.quote(str(histfile))}; " - "HISTCONTROL=ignorespace; shopt -s cmdhist; shopt -u lithist; " - "set -o history; history -c; history -w" + """An explicit false keeps the existing multiline command behavior.""" + first = "MULTILINE_CONTROL_FIRST" + second = "MULTILINE_CONTROL_SECOND" + output = tmp_path / "multiline-control.txt" + command = ( + f"printf '%s\\n' {first} > {shlex.quote(str(output))}\n" + f"printf '%s\\n' {second} >> {shlex.quote(str(output))}" ) - mcp_pane.send_keys(setup, enter=True) - retry_until(histfile.exists, 2, raises=True) - - control_first = "MULTILINE_CONTROL_FIRST" - control_second = "MULTILINE_CONTROL_SECOND" - protected_first = "MULTILINE_PROTECTED_FIRST" - protected_second = "MULTILINE_PROTECTED_SECOND" - control_output = tmp_path / "multiline-control.txt" - protected_output = tmp_path / "multiline-protected.txt" - control_command = ( - f"printf '%s\\n' {control_first} > {shlex.quote(str(control_output))}\n" - f"printf '%s\\n' {control_second} >> {shlex.quote(str(control_output))}" - ) - protected_command = ( - f"printf '%s\\n' {protected_first} > {shlex.quote(str(protected_output))}\n" - f"printf '%s\\n' {protected_second} >> {shlex.quote(str(protected_output))}" - ) - base = { - "pane_id": mcp_pane.pane_id, - "timeout": 3.0, - "socket_name": mcp_server.socket_name, - } async def _exercise() -> None: async with Client(_history_server("1")) as client: - control = await client.call_tool( + result = await client.call_tool( "run_command", { - **base, - "command": control_command, + "command": command, + "pane_id": mcp_pane.pane_id, + "timeout": 3.0, + "socket_name": mcp_server.socket_name, "suppress_history": False, }, raise_on_error=False, ) - _assert_run_command_succeeded(control) - protected = await client.call_tool( - "run_command", - {**base, "command": protected_command}, - raise_on_error=False, - ) - _assert_run_command_succeeded(protected) - flushed = await client.call_tool( - "run_command", - { - **base, - "command": "history -w", - "suppress_history": True, - }, - raise_on_error=False, - ) - _assert_run_command_succeeded(flushed) + _assert_run_command_succeeded(result) asyncio.run(_exercise()) - saved_lines = histfile.read_text().splitlines() - control_entries = [ - line for line in saved_lines if control_first in line or control_second in line - ] - assert control_output.read_text().splitlines() == [control_first, control_second] - assert protected_output.read_text().splitlines() == [ - protected_first, - protected_second, - ] - assert len(control_entries) == 1 - assert control_first in control_entries[0] - assert control_second in control_entries[0] - assert all(protected_first not in line for line in saved_lines) - assert all(protected_second not in line for line in saved_lines) + assert output.read_text().splitlines() == [first, second] From d610238f4592c0b51549802cbdeb0b45f4df0744 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 9 Jul 2026 22:15:44 -0500 Subject: [PATCH 09/20] mcp(docs[history]): Align safety guidance why: Keep public history and audit claims consistent with the accepted input shapes and source-link policy. what: - Distinguish mapping and JSON-string audit summaries - Link Bash behavior through a version-tagged GitHub mirror - Add documentation contract coverage --- docs/tools/pane/respawn-pane.md | 2 +- docs/topics/gotchas.md | 2 +- docs/topics/safety.md | 2 +- tests/docs/test_topic_contracts.py | 13 +++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 104cc222..65d282e5 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -69,7 +69,7 @@ time). } ``` -The audit log redacts each `environment` *value* via `{len, sha256_prefix}` digests but keeps the keys visible (env var names like `DATABASE_URL` are operator-debug-useful, while their values are the secret). Note that values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details. +Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, sha256_prefix}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details. Response (PaneInfo): diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 889325f1..800e8751 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -78,7 +78,7 @@ However, they reset when the tmux **server** restarts. Do not cache pane IDs acr ## Shell-history suppression is best effort -The `suppress_history` argument reduces one history-persistence path; it does not make terminal input secret. {tooliconl}`run-command` prepends one ASCII space to the grouped event that carries the caller's single-line command. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input; set `suppress_history=false` for intentional multiline input. For [Bash 5.3](https://git.savannah.gnu.org/gitweb/?p=bash.git;a=blob;f=doc/bashref.texi;hb=b8c60bc), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. +The `suppress_history` argument reduces one history-persistence path; it does not make terminal input secret. {tooliconl}`run-command` prepends one ASCII space to the grouped event that carries the caller's single-line command. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input; set `suppress_history=false` for intentional multiline input. For [Bash 5.3](https://github.com/tianon/mirror-bash/blob/bash-5.3/doc/bashref.texi#L7274-L7282), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. [Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) requires `HIST_IGNORE_SPACE` for the same prefix convention, and an ignored event can linger in internal history until the next event. [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) normally keeps a leading-space command off disk but leaves it recallable until the next command. A custom [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) function takes over that decision and can store leading-space commands, while [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) can strip leading spaces from pasted text. Do not treat the prefix convention as protection for paste input. diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 2b922897..571b44b2 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -139,7 +139,7 @@ Every tool call emits one `INFO` record on the `libtmux_mcp.audit` logger carryi - `outcome` — `ok` or `error`, with `error_type` on failure - `duration_ms` - `client_id` / `request_id` — from the fastmcp context when available -- `args` — a summary of arguments. Sensitive scalar keys (`keys`, `text`, `value`, `content`, `shell`) are replaced by `{len, sha256_prefix}`; the dict-shaped sensitive key `environment` keeps its keys but digests each value individually. Non-sensitive strings over 200 characters are truncated. +- `args` — a summary of arguments. Sensitive scalar keys (`keys`, `text`, `command`, `value`, `content`, `shell`, and string-form `environment`) are replaced by `{len, sha256_prefix}`. Mapping-form `environment` keeps its keys but digests each value individually. Non-sensitive strings over 200 characters are truncated. Route this logger to a dedicated sink if you want a durable audit trail; it is deliberately namespaced separately from the main `libtmux_mcp` logger. diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 476b77f4..0e29a85f 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -96,6 +96,7 @@ def test_history_gotcha_documents_shell_limits_and_raw_input_boundary( assert "do not inherit `LIBTMUX_SUPPRESS_HISTORY`" in text assert "control keys such as `C-c`, TUI input, or partial text" in text assert "Paste tools have no suppression argument" in text + assert "github.com/tianon/mirror-bash/blob/bash-5.3" in text assert "the default for bash" not in text @@ -180,6 +181,18 @@ def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( assert "does not contain tool return values" in text assert "Redaction applies only to these audit records" in text assert "libtmux, FastMCP, shells, or MCP clients" in text + assert "A JSON string is redacted as one scalar digest" in text + assert "dict-shaped sensitive key `environment`" not in text + + +def test_respawn_page_distinguishes_environment_audit_shapes( + docs_dir: pathlib.Path, +) -> None: + """Respawn guidance distinguishes mapping and JSON string redaction.""" + text = (docs_dir / "tools" / "pane" / "respawn-pane.md").read_text(encoding="utf-8") + + assert "Mapping input keeps the keys visible" in text + assert "A JSON object string is redacted as one scalar digest" in text def test_logging_docs_describe_audit_outcomes_without_return_values( From ff34aa7e6ce118ca50f9332346927e3f751c68a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 17:05:05 -0500 Subject: [PATCH 10/20] mcp(feat[history]): Default commands safe why: Semantic commands should avoid shell-history persistence by default while raw input and process spawning remain explicit caller choices. what: - Treat an unset suppression setting as enabled - Transform only the run_command MCP schema default - Document and test command-only inheritance boundaries --- src/libtmux_mcp/_history.py | 21 ++++++----------- src/libtmux_mcp/server.py | 6 ++--- tests/test_history.py | 47 ++++++++++++++++++++++++++++++------- tests/test_server.py | 21 +++++++++++++---- 4 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/libtmux_mcp/_history.py b/src/libtmux_mcp/_history.py index 5bff396a..b1f84edf 100644 --- a/src/libtmux_mcp/_history.py +++ b/src/libtmux_mcp/_history.py @@ -8,21 +8,15 @@ from fastmcp import FastMCP -_HISTORY_DEFAULT_TOOLS = ( - "run_command", - "create_session", - "create_window", - "split_window", - "respawn_pane", -) +_COMMAND_HISTORY_DEFAULT_TOOLS = ("run_command",) def _resolve_suppress_history(value: str | None) -> bool: """Resolve the strict startup history-suppression setting.""" - if value is None or value == "0": - return False - if value == "1": + if value is None or value == "1": return True + if value == "0": + return False msg = "LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'" raise ValueError(msg) @@ -31,9 +25,9 @@ def _configure_history_defaults( mcp: FastMCP, enabled: bool, *, - tool_names: tuple[str, ...] = _HISTORY_DEFAULT_TOOLS, + tool_names: tuple[str, ...] = _COMMAND_HISTORY_DEFAULT_TOOLS, ) -> None: - """Publish the effective MCP default for semantic command and spawn tools. + """Publish the effective MCP default for semantic command tools. Parameters ---------- @@ -42,8 +36,7 @@ def _configure_history_defaults( enabled : bool Effective startup default to publish. tool_names : tuple[str, ...] - Semantic tool names that inherit the default when omitted by an MCP - caller. + Command tool names that inherit the default when omitted by an MCP caller. """ from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ArgTransformConfig, ToolTransformConfig diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 38653545..f92a6404 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -139,7 +139,7 @@ def _build_instructions( safety_level: str = TAG_MUTATING, - suppress_history: bool = False, + suppress_history: bool = True, ) -> str: """Build server instructions with agent context and safety level. @@ -169,8 +169,8 @@ def _build_instructions( ) history_default = "true" if suppress_history else "false" parts.append( - f"\n\nsuppress_history={history_default}: run_command/spawn inherit; " - "raw send/batch/paste do not." + f"\n\nsuppress_history={history_default}: run_command inherits; " + "raw send/batch/paste and spawn do not." ) # Tier-conditioned discoverability hint. False-positive activation is diff --git a/tests/test_history.py b/tests/test_history.py index 33198a00..46aa3297 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -31,7 +31,7 @@ class SuppressHistorySettingFixture(t.NamedTuple): SUPPRESS_HISTORY_SETTING_FIXTURES = [ - SuppressHistorySettingFixture("unset", None, False), + SuppressHistorySettingFixture("unset", None, True), SuppressHistorySettingFixture("disabled", "0", False), SuppressHistorySettingFixture("enabled", "1", True), ] @@ -113,17 +113,18 @@ def test_resolve_suppress_history_rejects_invalid_without_echoing_value() -> Non def test_history_transform_changes_exact_semantic_tool_set() -> None: - """Only run-command and the four spawn tools inherit one boolean default.""" + """Only run-command inherits the command-history boolean default.""" from libtmux_mcp._history import _configure_history_defaults from libtmux_mcp.tools import register_tools - semantic_tools = { - "run_command", + command_tools = {"run_command"} + spawn_tools = { "create_session", "create_window", "split_window", "respawn_pane", } + history_tools = command_tools | spawn_tools | {"send_keys"} async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: mcp = FastMCP(f"history-transform-{enabled}") @@ -145,14 +146,17 @@ async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: if schema["default"] != disabled[name]["default"] } - assert changed == semantic_tools + assert set(disabled) == history_tools + assert set(enabled) == history_tools + assert changed == command_tools for default, schemas in ((False, disabled), (True, enabled)): - for name in semantic_tools: + for name in history_tools: schema = schemas[name] assert schema["type"] == "boolean" - assert schema["default"] is default + expected = default if name in command_tools else False + assert schema["default"] is expected assert "anyOf" not in schema - if name != "run_command": + if name in spawn_tools: description = " ".join(schema["description"].split()) assert description == SPAWN_SUPPRESS_HISTORY_DESCRIPTION @@ -354,6 +358,23 @@ async def main(): mode="json", exclude_none=True ), "tags": tool.meta["fastmcp"]["tags"], + "raw_defaults": { + "send_keys": tools["send_keys"].inputSchema["properties"] + ["suppress_history"]["default"], + "send_keys_batch": tools["send_keys_batch"].inputSchema + ["properties"]["operations"]["items"]["properties"] + ["suppress_history"]["default"], + }, + "spawn_defaults": { + name: tools[name].inputSchema["properties"] + ["suppress_history"]["default"] + for name in ( + "create_session", + "create_window", + "split_window", + "respawn_pane", + ) + }, "spawn_descriptions": { name: tools[name].inputSchema["properties"] ["suppress_history"]["description"] @@ -397,6 +418,16 @@ async def main(): "readOnlyHint": False, } assert payload["tags"] == ["mutating"] + assert payload["raw_defaults"] == { + "send_keys": False, + "send_keys_batch": False, + } + assert payload["spawn_defaults"] == { + "create_session": False, + "create_window": False, + "split_window": False, + "respawn_pane": False, + } expected_descriptions = dict.fromkeys( ("create_session", "create_window", "split_window", "respawn_pane"), SPAWN_SUPPRESS_HISTORY_DESCRIPTION, diff --git a/tests/test_server.py b/tests/test_server.py index daad3176..bcc8027a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -414,8 +414,18 @@ def test_build_instructions_documents_semantic_history_default_and_raw_boundary( instructions = _build_instructions(suppress_history=suppress_history) assert ( - f"suppress_history={expected_default}: run_command/spawn inherit; " - "raw send/batch/paste do not." + f"suppress_history={expected_default}: run_command inherits; " + "raw send/batch/paste and spawn do not." + ) in instructions + + +def test_build_instructions_defaults_semantic_history_suppression_on() -> None: + """Instructions default command-history suppression to enabled.""" + instructions = _build_instructions() + + assert ( + "suppress_history=true: run_command inherits; " + "raw send/batch/paste and spawn do not." ) in instructions @@ -505,7 +515,8 @@ def test_instruction_budget_drops_oversized_socket_before_required_text( assert len(instructions.encode("utf-8")) <= 2048 assert _BASE_INSTRUCTIONS in instructions assert ( - "suppress_history=true: run_command/spawn inherit; raw send/batch/paste do not." + "suppress_history=true: run_command inherits; " + "raw send/batch/paste and spawn do not." ) in instructions assert "Agent context" in instructions assert "%42" in instructions @@ -528,8 +539,8 @@ def test_instruction_budget_can_drop_all_oversized_optional_context( assert len(instructions.encode("utf-8")) <= 2048 assert _BASE_INSTRUCTIONS in instructions assert ( - "suppress_history=false: run_command/spawn inherit; " - "raw send/batch/paste do not." + "suppress_history=false: run_command inherits; " + "raw send/batch/paste and spawn do not." ) in instructions assert "Agent context" not in instructions From d1c37d6c468f0ac0285b22eb3766ac44f2b81ba4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 17:10:41 -0500 Subject: [PATCH 11/20] mcp(feat[spawn]): Split history controls why: Distinguish lightweight command-entry suppression from explicit persistent-history controls for newly spawned shells. what: - Rename the spawn-only option to suppress_persistent_history - Keep startup history defaults scoped to semantic commands - Preserve validated environment merging and tmux spawn behavior --- src/libtmux_mcp/_history.py | 8 +- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 13 +- src/libtmux_mcp/tools/server_tools.py | 13 +- src/libtmux_mcp/tools/session_tools.py | 13 +- src/libtmux_mcp/tools/window_tools.py | 13 +- tests/test_history.py | 60 +++++---- tests/test_spawn_shell_history.py | 4 +- tests/test_spawn_tools_history.py | 126 +++++++++--------- 8 files changed, 133 insertions(+), 117 deletions(-) diff --git a/src/libtmux_mcp/_history.py b/src/libtmux_mcp/_history.py index b1f84edf..1c42bf0c 100644 --- a/src/libtmux_mcp/_history.py +++ b/src/libtmux_mcp/_history.py @@ -55,7 +55,7 @@ def _configure_history_defaults( def _prepare_spawn_environment( environment: dict[str, str] | str | None, *, - suppress_history: bool, + suppress_persistent_history: bool, ) -> dict[str, str] | None: """Copy and normalize an environment for a newly spawned process. @@ -63,7 +63,7 @@ def _prepare_spawn_environment( ---------- environment : dict, str, or None Environment mapping or JSON-object string supplied by the caller. - suppress_history : bool + suppress_persistent_history : bool Whether to merge the best-effort shell-history controls. Returns @@ -92,7 +92,7 @@ def _prepare_spawn_environment( raise ExpectedToolError(msg) result = dict(coerced) - if not suppress_history: + if not suppress_persistent_history: return None if coerced is None else result required_values = { @@ -109,7 +109,7 @@ def _prepare_spawn_environment( if name in result and result[name] != required: msg = ( f"environment variable {name} conflicts with " - f"suppress_history=True; {corrections[name]}" + f"suppress_persistent_history=True; {corrections[name]}" ) raise ExpectedToolError(msg) result[name] = required diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 5dc797d1..56c702c4 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -73,7 +73,7 @@ def respawn_pane( environment: dict[str, str] | str | None = None, socket_name: str | None = None, *, - suppress_history: bool = False, + suppress_persistent_history: bool = False, ) -> PaneInfo: """Restart a pane's process in place, preserving pane_id and layout. @@ -134,10 +134,11 @@ def respawn_pane( host-resident agent or other tenant could observe ``ps``. socket_name : str, optional tmux socket name. - suppress_history : bool - For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY - default; an explicit value overrides it. Direct Python calls default - to False. Startup files may override these controls. + suppress_persistent_history : bool + Whether to suppress persistent history for the spawned shell. Defaults + to False for MCP and direct Python calls. This per-call option does not + inherit LIBTMUX_SUPPRESS_HISTORY. Startup files may override these + controls. Returns ------- @@ -147,7 +148,7 @@ def respawn_pane( """ spawn_environment = _prepare_spawn_environment( environment, - suppress_history=suppress_history, + suppress_persistent_history=suppress_persistent_history, ) server = _get_server(socket_name=socket_name) pane = _resolve_pane(server, pane_id=pane_id) diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index 9bd173ea..d2938240 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -76,7 +76,7 @@ def create_session( environment: dict[str, str] | str | None = None, socket_name: str | None = None, *, - suppress_history: bool = False, + suppress_persistent_history: bool = False, ) -> SessionInfo: """Create a new tmux session. @@ -103,10 +103,11 @@ def create_session( :func:`libtmux_mcp._utils._coerce_dict_arg`. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. - suppress_history : bool - For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY - default; an explicit value overrides it. Direct Python calls default - to False. Startup files may override these controls. + suppress_persistent_history : bool + Whether to suppress persistent history for the spawned shell. Defaults + to False for MCP and direct Python calls. This per-call option does not + inherit LIBTMUX_SUPPRESS_HISTORY. Startup files may override these + controls. Returns ------- @@ -115,7 +116,7 @@ def create_session( """ spawn_environment = _prepare_spawn_environment( environment, - suppress_history=suppress_history, + suppress_persistent_history=suppress_persistent_history, ) server = _get_server(socket_name=socket_name) kwargs: dict[str, t.Any] = {} diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 39bc711c..0bdc8e69 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -120,7 +120,7 @@ def create_window( socket_name: str | None = None, *, environment: dict[str, str] | str | None = None, - suppress_history: bool = False, + suppress_persistent_history: bool = False, ) -> WindowInfo: """Create a new window in a tmux session. @@ -145,10 +145,11 @@ def create_window( environment : dict or str, optional Per-process environment as a mapping or JSON object string. Values do not modify the tmux session environment. - suppress_history : bool - For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY - default; an explicit value overrides it. Direct Python calls default - to False. Startup files may override these controls. + suppress_persistent_history : bool + Whether to suppress persistent history for the spawned shell. Defaults + to False for MCP and direct Python calls. This per-call option does not + inherit LIBTMUX_SUPPRESS_HISTORY. Startup files may override these + controls. Returns ------- @@ -157,7 +158,7 @@ def create_window( """ spawn_environment = _prepare_spawn_environment( environment, - suppress_history=suppress_history, + suppress_persistent_history=suppress_persistent_history, ) server = _get_server(socket_name=socket_name) session = _resolve_session(server, session_name=session_name, session_id=session_id) diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 9d7c15b3..1fd593ab 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -165,7 +165,7 @@ def split_window( socket_name: str | None = None, *, environment: dict[str, str] | str | None = None, - suppress_history: bool = False, + suppress_persistent_history: bool = False, ) -> PaneInfo: """Split a tmux window to create a new pane. @@ -198,10 +198,11 @@ def split_window( environment : dict or str, optional Per-process environment as a mapping or JSON object string. Values do not modify the tmux session environment. - suppress_history : bool - For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY - default; an explicit value overrides it. Direct Python calls default - to False. Startup files may override these controls. + suppress_persistent_history : bool + Whether to suppress persistent history for the spawned shell. Defaults + to False for MCP and direct Python calls. This per-call option does not + inherit LIBTMUX_SUPPRESS_HISTORY. Startup files may override these + controls. Returns ------- @@ -210,7 +211,7 @@ def split_window( """ spawn_environment = _prepare_spawn_environment( environment, - suppress_history=suppress_history, + suppress_persistent_history=suppress_persistent_history, ) server = _get_server(socket_name=socket_name) diff --git a/tests/test_history.py b/tests/test_history.py index 46aa3297..70929de9 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -50,10 +50,11 @@ class McpHistoryBehaviorFixture(t.NamedTuple): McpHistoryBehaviorFixture("startup_enabled", "1", False), ] -SPAWN_SUPPRESS_HISTORY_DESCRIPTION = ( - "For MCP calls, omission uses the server's LIBTMUX_SUPPRESS_HISTORY " - "default; an explicit value overrides it. Direct Python calls default to " - "False. Startup files may override these controls." +SPAWN_SUPPRESS_PERSISTENT_HISTORY_DESCRIPTION = ( + "Whether to suppress persistent history for the spawned shell. Defaults " + "to False for MCP and direct Python calls. This per-call option does not " + "inherit LIBTMUX_SUPPRESS_HISTORY. Startup files may override these " + "controls." ) @@ -124,7 +125,8 @@ def test_history_transform_changes_exact_semantic_tool_set() -> None: "split_window", "respawn_pane", } - history_tools = command_tools | spawn_tools | {"send_keys"} + raw_tools = {"send_keys"} + history_tools = command_tools | raw_tools async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: mcp = FastMCP(f"history-transform-{enabled}") @@ -132,11 +134,15 @@ async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: _configure_history_defaults(mcp, enabled) async with Client(mcp) as client: tools = await client.list_tools() - return { - tool.name: tool.inputSchema["properties"]["suppress_history"] - for tool in tools - if "suppress_history" in tool.inputSchema["properties"] - } + schemas: dict[str, dict[str, t.Any]] = {} + for tool in tools: + properties = tool.inputSchema["properties"] + if tool.name in spawn_tools: + assert "suppress_history" not in properties + schemas[tool.name] = properties["suppress_persistent_history"] + elif "suppress_history" in properties: + schemas[tool.name] = properties["suppress_history"] + return schemas disabled = asyncio.run(_schemas(False)) enabled = asyncio.run(_schemas(True)) @@ -146,11 +152,11 @@ async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: if schema["default"] != disabled[name]["default"] } - assert set(disabled) == history_tools - assert set(enabled) == history_tools + assert set(disabled) == history_tools | spawn_tools + assert set(enabled) == history_tools | spawn_tools assert changed == command_tools for default, schemas in ((False, disabled), (True, enabled)): - for name in history_tools: + for name in history_tools | spawn_tools: schema = schemas[name] assert schema["type"] == "boolean" expected = default if name in command_tools else False @@ -158,7 +164,7 @@ async def _schemas(enabled: bool) -> dict[str, dict[str, t.Any]]: assert "anyOf" not in schema if name in spawn_tools: description = " ".join(schema["description"].split()) - assert description == SPAWN_SUPPRESS_HISTORY_DESCRIPTION + assert description == SPAWN_SUPPRESS_PERSISTENT_HISTORY_DESCRIPTION class SpawnEnvironmentFixture(t.NamedTuple): @@ -166,7 +172,7 @@ class SpawnEnvironmentFixture(t.NamedTuple): test_id: str environment: dict[str, str] | str | None - suppress_history: bool + suppress_persistent_history: bool expected: dict[str, str] | None @@ -231,7 +237,7 @@ class SpawnEnvironmentFixture(t.NamedTuple): def test_prepare_spawn_environment_normalizes_copies_and_merges( test_id: str, environment: dict[str, str] | str | None, - suppress_history: bool, + suppress_persistent_history: bool, expected: dict[str, str] | None, ) -> None: """Spawn environments are normalized without modifying caller input.""" @@ -239,7 +245,10 @@ def test_prepare_spawn_environment_normalizes_copies_and_merges( assert test_id original = environment.copy() if isinstance(environment, dict) else environment - result = _prepare_spawn_environment(environment, suppress_history=suppress_history) + result = _prepare_spawn_environment( + environment, + suppress_persistent_history=suppress_persistent_history, + ) assert result == expected if isinstance(environment, dict): @@ -281,12 +290,13 @@ def test_prepare_spawn_environment_rejects_conflicts_without_values( environment = {"UNCHANGED": "caller", name: supplied} original = environment.copy() expected = ( - f"environment variable {name} conflicts with suppress_history=True; " + f"environment variable {name} conflicts with " + "suppress_persistent_history=True; " f"{correction}" ) with pytest.raises(ToolError) as excinfo: - _prepare_spawn_environment(environment, suppress_history=True) + _prepare_spawn_environment(environment, suppress_persistent_history=True) assert str(excinfo.value) == expected assert supplied not in str(excinfo.value) @@ -312,7 +322,7 @@ def test_prepare_spawn_environment_rejects_non_string_items( original = environment.copy() if isinstance(environment, dict) else environment with pytest.raises(ToolError) as excinfo: - _prepare_spawn_environment(environment, suppress_history=False) + _prepare_spawn_environment(environment, suppress_persistent_history=False) assert str(excinfo.value) == "environment keys and values must be strings" if isinstance(environment, dict): @@ -324,12 +334,12 @@ def test_prepare_spawn_environment_rejects_non_string_items( SUPPRESS_HISTORY_SETTING_FIXTURES, ids=[fixture.test_id for fixture in SUPPRESS_HISTORY_SETTING_FIXTURES], ) -def test_production_mcp_schema_uses_startup_history_default( +def test_production_mcp_schema_scopes_startup_default_to_run_command( test_id: str, value: str | None, expected: bool, ) -> None: - """A fresh server publishes one stable transform with retained metadata.""" + """Startup configuration never changes persistent-history spawn defaults.""" script = textwrap.dedent( """ import asyncio @@ -367,7 +377,7 @@ async def main(): }, "spawn_defaults": { name: tools[name].inputSchema["properties"] - ["suppress_history"]["default"] + ["suppress_persistent_history"]["default"] for name in ( "create_session", "create_window", @@ -377,7 +387,7 @@ async def main(): }, "spawn_descriptions": { name: tools[name].inputSchema["properties"] - ["suppress_history"]["description"] + ["suppress_persistent_history"]["description"] for name in ( "create_session", "create_window", @@ -430,7 +440,7 @@ async def main(): } expected_descriptions = dict.fromkeys( ("create_session", "create_window", "split_window", "respawn_pane"), - SPAWN_SUPPRESS_HISTORY_DESCRIPTION, + SPAWN_SUPPRESS_PERSISTENT_HISTORY_DESCRIPTION, ) assert { name: " ".join(description.split()) diff --git a/tests/test_spawn_shell_history.py b/tests/test_spawn_shell_history.py index 28d0c490..d562d204 100644 --- a/tests/test_spawn_shell_history.py +++ b/tests/test_spawn_shell_history.py @@ -49,7 +49,7 @@ def _exercise_spawned_shell_history( shell=shell, socket_name=mcp_server.socket_name, environment={"HOME": str(home), "XDG_DATA_HOME": str(data_home)}, - suppress_history=True, + suppress_persistent_history=True, ) assert pane_info.pane_id is not None pane = mcp_server.panes.get(pane_id=pane_info.pane_id, default=None) @@ -204,7 +204,7 @@ def _exercise_shell_startup_override( shell=shlex.join((executable, *arguments)), socket_name=mcp_server.socket_name, environment=environment, - suppress_history=True, + suppress_persistent_history=True, ) assert pane_info.pane_id is not None pane = mcp_server.panes.get(pane_id=pane_info.pane_id, default=None) diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py index 131a2f0a..04d9a673 100644 --- a/tests/test_spawn_tools_history.py +++ b/tests/test_spawn_tools_history.py @@ -39,7 +39,7 @@ def test_spawn_tool_signatures_preserve_positional_slots() -> None: "environment", "socket_name", ), - ("suppress_history",), + ("suppress_persistent_history",), ), create_window: ( ( @@ -51,7 +51,7 @@ def test_spawn_tool_signatures_preserve_positional_slots() -> None: "direction", "socket_name", ), - ("environment", "suppress_history"), + ("environment", "suppress_persistent_history"), ), split_window: ( ( @@ -66,7 +66,7 @@ def test_spawn_tool_signatures_preserve_positional_slots() -> None: "shell", "socket_name", ), - ("environment", "suppress_history"), + ("environment", "suppress_persistent_history"), ), respawn_pane: ( ( @@ -77,7 +77,7 @@ def test_spawn_tool_signatures_preserve_positional_slots() -> None: "environment", "socket_name", ), - ("suppress_history",), + ("suppress_persistent_history",), ), } @@ -93,7 +93,7 @@ def test_spawn_tool_signatures_preserve_positional_slots() -> None: parameters[name].kind is inspect.Parameter.KEYWORD_ONLY for name in keyword_only_names ) - assert parameters["suppress_history"].default is False + assert parameters["suppress_persistent_history"].default is False bound = signature.bind(*([None] * len(positional_names))) assert tuple(bound.arguments) == positional_names with pytest.raises(TypeError): @@ -115,9 +115,12 @@ def test_spawn_environment_schemas_are_client_compatible() -> None: "string", "null", } - suppress_history = tools[name].parameters["properties"]["suppress_history"] - assert suppress_history["type"] == "boolean" - assert suppress_history["default"] is False + suppress_persistent_history = tools[name].parameters["properties"][ + "suppress_persistent_history" + ] + assert "suppress_history" not in tools[name].parameters["properties"] + assert suppress_persistent_history["type"] == "boolean" + assert suppress_persistent_history["default"] is False def _assert_value_free_spawn_conflict(call: t.Callable[[], t.Any], name: str) -> None: @@ -129,7 +132,8 @@ def _assert_value_free_spawn_conflict(call: t.Callable[[], t.Any], name: str) -> call() assert str(excinfo.value) == ( - f"environment variable {name} conflicts with suppress_history=True; " + f"environment variable {name} conflicts with " + "suppress_persistent_history=True; " "omit it or set it to an empty string" ) assert supplied not in str(excinfo.value) @@ -156,20 +160,20 @@ def _unexpected_get_server(**_kwargs: t.Any) -> t.NoReturn: spawn_calls = ( lambda: server_tools.create_session( environment=environment, - suppress_history=True, + suppress_persistent_history=True, ), lambda: session_tools.create_window( environment=environment, - suppress_history=True, + suppress_persistent_history=True, ), lambda: window_tools.split_window( environment=environment, - suppress_history=True, + suppress_persistent_history=True, ), lambda: lifecycle.respawn_pane( pane_id="%1", environment=environment, - suppress_history=True, + suppress_persistent_history=True, ), ) @@ -198,7 +202,7 @@ def test_spawn_conflicts_create_nothing_and_never_retry_unsuppressed( session_name="history_conflict_session", environment=environment, socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ), "HISTFILE", ) @@ -211,7 +215,7 @@ def test_spawn_conflicts_create_nothing_and_never_retry_unsuppressed( window_name="history_conflict_window", socket_name=mcp_server.socket_name, environment=environment, - suppress_history=True, + suppress_persistent_history=True, ), "HISTFILE", ) @@ -224,7 +228,7 @@ def test_spawn_conflicts_create_nothing_and_never_retry_unsuppressed( window_id=window.window_id, socket_name=mcp_server.socket_name, environment=environment, - suppress_history=True, + suppress_persistent_history=True, ), "HISTFILE", ) @@ -240,7 +244,7 @@ def test_spawn_conflicts_create_nothing_and_never_retry_unsuppressed( pane_id=t.cast("str", pane.pane_id), environment=environment, socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ), "HISTFILE", ) @@ -250,11 +254,11 @@ def test_spawn_conflicts_create_nothing_and_never_retry_unsuppressed( pane.kill() -def test_mcp_spawn_explicit_false_overrides_enabled_default( +def test_mcp_spawn_omission_ignores_enabled_command_default( mcp_server: Server, mcp_session: Session, ) -> None: - """An explicit false override permits a caller history environment.""" + """The command-history default never enables persistent spawn controls.""" from libtmux_mcp._history import _configure_history_defaults from libtmux_mcp.tools import session_tools @@ -271,46 +275,44 @@ def test_mcp_spawn_explicit_false_overrides_enabled_default( async def _exercise() -> None: async with Client(mcp) as client: - inherited = await client.call_tool( + omitted = await client.call_tool( "create_window", - {**base, "window_name": "spawn_default_conflict"}, + {**base, "window_name": "spawn_omitted"}, raise_on_error=False, ) - assert inherited.is_error is True - assert inherited.content - error = inherited.content[0].text - assert error == ( - "environment variable HISTFILE conflicts with " - "suppress_history=True; omit it or set it to an empty string" - ) - assert supplied not in error - assert {window.window_id for window in mcp_session.windows} == before + assert omitted.is_error is False + assert omitted.structured_content is not None + assert omitted.structured_content["window_name"] == "spawn_omitted" - overridden = await client.call_tool( + suppressed = await client.call_tool( "create_window", { **base, - "window_name": "spawn_explicit_false", - "suppress_history": False, + "window_name": "spawn_explicit_true_conflict", + "suppress_persistent_history": True, }, raise_on_error=False, ) - assert overridden.is_error is False - assert overridden.structured_content is not None - assert overridden.structured_content["window_name"] == ( - "spawn_explicit_false" + assert suppressed.is_error is True + assert suppressed.content + error = suppressed.content[0].text + assert error == ( + "environment variable HISTFILE conflicts with " + "suppress_persistent_history=True; " + "omit it or set it to an empty string" ) + assert supplied not in error asyncio.run(_exercise()) created = [ window for window in mcp_session.windows if window.window_id not in before ] assert len(created) == 1 - assert created[0].window_name == "spawn_explicit_false" + assert created[0].window_name == "spawn_omitted" def _spawn_history_server(enabled: bool) -> FastMCP: - """Build a complete server for spawn transforms and generic batches.""" + """Build a complete server for command transforms and generic batches.""" from libtmux_mcp._history import _configure_history_defaults from libtmux_mcp.tools import register_tools @@ -320,11 +322,11 @@ def _spawn_history_server(enabled: bool) -> FastMCP: return mcp -def test_generic_batch_spawn_uses_default_and_explicit_false_override( +def test_generic_batch_spawn_ignores_command_default_unless_opted_in( mcp_server: Server, mcp_session: Session, ) -> None: - """Nested spawn calls retain transform defaults, errors, and inner state.""" + """Nested spawn calls retain their explicit persistent-history option.""" supplied = "private-nested-history-path" before = {window.window_id for window in mcp_session.windows} base = { @@ -344,15 +346,15 @@ async def _exercise() -> t.Any: "tool": "create_window", "arguments": { **base, - "window_name": "batch_spawn_default_conflict", + "window_name": "batch_spawn_omitted", }, }, { "tool": "create_window", "arguments": { **base, - "window_name": "batch_spawn_explicit_false", - "suppress_history": False, + "window_name": "batch_spawn_explicit_true_conflict", + "suppress_persistent_history": True, }, }, ], @@ -367,17 +369,16 @@ async def _exercise() -> t.Any: assert result.structured_content["succeeded"] == 1 assert result.structured_content["failed"] == 1 assert result.structured_content["stopped_at"] is None - failed, succeeded = result.structured_content["results"] + succeeded, failed = result.structured_content["results"] + assert succeeded["success"] is True + assert succeeded["structured_content"]["window_name"] == "batch_spawn_omitted" assert failed["success"] is False assert failed["error"] == ( - "environment variable HISTFILE conflicts with suppress_history=True; " + "environment variable HISTFILE conflicts with " + "suppress_persistent_history=True; " "omit it or set it to an empty string" ) assert supplied not in failed["error"] - assert succeeded["success"] is True - assert succeeded["structured_content"]["window_name"] == ( - "batch_spawn_explicit_false" - ) created = {window.window_id for window in mcp_session.windows} - before assert len(created) == 1 @@ -401,7 +402,7 @@ async def _exercise() -> t.Any: "window_name": "spawn_log_conflict", "environment": {"HISTFILE": supplied}, "socket_name": mcp_server.socket_name, - "suppress_history": True, + "suppress_persistent_history": True, }, raise_on_error=False, ) @@ -412,7 +413,8 @@ async def _exercise() -> t.Any: assert result.is_error is True assert result.content assert result.content[0].text == ( - "environment variable HISTFILE conflicts with suppress_history=True; " + "environment variable HISTFILE conflicts with " + "suppress_persistent_history=True; " "omit it or set it to an empty string" ) assert supplied not in repr(result) @@ -472,7 +474,7 @@ def test_spawn_tools_forward_process_environment_without_session_leakage( window_name="history_process_window", socket_name=mcp_server.socket_name, environment='{"SPAWN_SCOPE_MARKER":"window"}', - suppress_history=True, + suppress_persistent_history=True, ) assert window_info.active_pane_id is not None window_pane = mcp_server.panes.get( @@ -503,7 +505,7 @@ def test_spawn_tools_forward_process_environment_without_session_leakage( "SPAWN_SCOPE_MARKER": "split", "HISTCONTROL": "ignoredups", }, - suppress_history=True, + suppress_persistent_history=True, ) assert pane_info.pane_id is not None split_pane = mcp_server.panes.get(pane_id=pane_info.pane_id, default=None) @@ -525,7 +527,7 @@ def test_spawn_tools_forward_process_environment_without_session_leakage( "SPAWN_SCOPE_MARKER": "window-branch", "HISTCONTROL": "ignoredups", }, - suppress_history=True, + suppress_persistent_history=True, ) assert window_branch_info.pane_id is not None window_branch_pane = mcp_server.panes.get( @@ -574,7 +576,7 @@ def test_spawn_tools_forward_process_environment_without_session_leakage( shell="sh", environment=('{"SPAWN_SCOPE_MARKER":"respawn","HISTCONTROL":"ignoredups"}'), socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ) _assert_pane_environment( respawn_target, @@ -626,7 +628,7 @@ def test_create_session_history_environment_reaches_future_panes( session_name=session_name, environment={"SPAWN_SCOPE_MARKER": "session", "HISTCONTROL": "ignoredups"}, socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ) session = mcp_server.sessions.get(session_name=session_name, default=None) assert session is not None @@ -670,7 +672,7 @@ def test_explicit_false_keeps_inherited_session_history_environment( session_name=session_name, environment={"HISTCONTROL": "ignoredups"}, socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ) session = mcp_server.sessions.get(session_name=session_name, default=None) assert session is not None @@ -692,7 +694,7 @@ def test_explicit_false_keeps_inherited_session_history_environment( window_name="explicit_false_inherited", socket_name=mcp_server.socket_name, environment={"EXPLICIT_FALSE_MARKER": "process"}, - suppress_history=False, + suppress_persistent_history=False, ) assert window_info.active_pane_id is not None pane = mcp_server.panes.get( @@ -733,13 +735,13 @@ def test_spawn_tools_preserve_direct_launch_strings( pane_id=mcp_pane.pane_id, shell=pane_branch_shell, socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ) window_branch = split_window( window_id=mcp_pane.window.window_id, shell=window_branch_shell, socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ) split_commands = [ shlex.split(t.cast("str", record.__dict__["tmux_cmd"])) @@ -762,7 +764,7 @@ def test_spawn_tools_preserve_direct_launch_strings( pane_id=pane_branch.pane_id, shell=respawn_shell, socket_name=mcp_server.socket_name, - suppress_history=True, + suppress_persistent_history=True, ) respawn_commands = [ shlex.split(t.cast("str", record.__dict__["tmux_cmd"])) From 13745507fe1679a6e3b9d204b9ef3a9c69332978 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 17:14:00 -0500 Subject: [PATCH 12/20] mcp(docs[history]): Explain two controls why: Distinguish default-on command suppression from explicit spawn-time persistent-history controls. what: - Document command precedence and the multiline opt-out - Explain spawn scope, shell caveats, and visibility boundaries - Lock the public history contract with documentation tests --- docs/configuration.md | 14 +++--- docs/tools/pane/respawn-pane.md | 6 ++- docs/tools/pane/run-command.md | 4 +- docs/tools/server/create-session.md | 6 ++- docs/tools/session/create-window.md | 6 ++- docs/tools/window/split-window.md | 6 ++- docs/topics/gotchas.md | 12 +++-- docs/topics/logging.md | 4 ++ docs/topics/safety.md | 2 +- tests/docs/test_topic_contracts.py | 70 +++++++++++++++++++++++------ 10 files changed, 97 insertions(+), 33 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 75fb15aa..f1a82144 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,19 +42,21 @@ Safety tier controlling which tools are available. See {ref}`safety`. ```{envvar} LIBTMUX_SUPPRESS_HISTORY ``` -Controls the MCP default for best-effort shell-history suppression. This setting applies when an MCP caller omits `suppress_history` from {tooliconl}`run-command`, {tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, or {tooliconl}`respawn-pane`. +Controls the MCP default for lightweight, best-effort command-history suppression. This setting applies only when an MCP caller omits `suppress_history` from {tooliconl}`run-command`. - **Type:** string flag -- **Default:** `0` (disabled) +- **Default:** `1` (enabled) - **Values:** `0`, `1` -Unset and `0` disable suppression; `1` enables it. Any other value fails server startup with `LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'`, without echoing the rejected value. Precedence is per call: an explicit `suppress_history` value wins, then {envvar}`LIBTMUX_SUPPRESS_HISTORY`, then the default `False`. Direct Python calls also default to `False`. +Unset and `1` enable suppression; `0` disables it. Any other value fails server startup with `LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'`, without echoing the rejected value. An explicit `suppress_history` value wins for each MCP call. Direct Python calls default to `False`. -The tools apply that value in two different ways. {toolref}`run-command` prefixes one space to the grouped event that carries the caller's single-line command. When suppression is effective, a command containing a carriage return or line feed fails before tmux receives input; set `suppress_history=false` for intentional multiline input. Each of the four spawn tools copies and merges the caller's environment with shell-history controls before starting a process. A conflicting caller-supplied history value fails the call, names the environment variable without including the conflicting value, and is never retried without suppression. +{toolref}`run-command` prefixes one space to the grouped event that carries your single-line command. When suppression is effective, a command containing a carriage return or line feed fails before tmux receives input; set `suppress_history=false` for intentional multiline input. -An explicit `suppress_history=false` stops the tool from adding a prefix or new environment controls for that call. It does not remove controls that the target process already inherits from a session, parent environment, or shell startup file. The startup default never changes the raw-input behavior of {toolref}`send-keys`, {toolref}`send-keys-batch`, {toolref}`paste-text`, or {toolref}`paste-buffer`. +Process creation uses a separate control. {tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` expose `suppress_persistent_history`, which defaults to `false` for MCP and direct Python calls and never inherits this startup setting. Setting it to `true` copies and merges best-effort no-disk history controls into the spawned environment. A conflicting caller-supplied history value fails the call, names the environment variable without including the conflicting value, and is never retried without suppression. -The server resolves this setting once during startup. After changing it, restart the MCP server, usually by reconnecting or restarting the MCP client. See {ref}`history-hygiene` for the shell-specific limits and {ref}`safety` for surfaces that history suppression does not hide. +Leaving it `false` adds no history controls. That choice cannot remove inherited, session, or startup-file controls; the process can still receive them from tmux, your supplied `environment`, or a shell startup file. The startup default never changes the raw-input behavior of {toolref}`send-keys`, {toolref}`send-keys-batch`, {toolref}`paste-text`, or {toolref}`paste-buffer`. + +The server resolves {envvar}`LIBTMUX_SUPPRESS_HISTORY` once during startup. Restart the MCP server only after changing this startup setting, usually by reconnecting or restarting the MCP client. Per-call arguments take effect without a restart. See {ref}`history-hygiene` for shell-specific limits and {ref}`safety` for surfaces that history suppression does not hide. ## Setting environment variables diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 65d282e5..8b06fa6b 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -19,9 +19,11 @@ the layout: `respawn-pane` preserves the pane in place. starts a new one. **The `pane_id` is preserved** — that's the whole point of the tool. `pane_pid` updates to the new process. -For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`respawn-pane` adds an environment that applies to only the spawned process; it does not change the tmux session environment or affect later panes. An explicit `false` prevents new controls but does not remove controls inherited by the pane process. Shell startup files can override the controls; see {ref}`history-hygiene` and {ref}`safety`. +`suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. -The history policy only copies and merges environment values; it does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. +Set it to `true` and {tooliconl}`respawn-pane` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment or affect later panes. The shell can retain in-memory history, and a startup file can override these controls after the process starts. + +The history policy does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Tip:** Call {tooliconl}`get-pane-info` first if you need to capture `pane_current_command` before respawn — the new process loses its argv. diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md index f39883be..aee99444 100644 --- a/docs/tools/pane/run-command.md +++ b/docs/tools/pane/run-command.md @@ -12,7 +12,9 @@ names, and partial commands. **Side effects:** Sends a command to the pane's interactive shell. The command may read or write files, start processes, or access the network depending on what the shell command does. Each command runs in a subshell, so directory or environment changes do not persist across calls. -For MCP calls, the {ref}`configuration ` setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` supplies the value when this argument is omitted, and an explicit `suppress_history` value wins. Direct Python calls default to `False`. Suppression is best effort: {tooliconl}`run-command` prefixes one space to the grouped event that carries the caller's single-line command, but the existing shell must be configured to ignore space-prefixed commands. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input because a prefix cannot protect each shell event. Set `suppress_history=false` to keep intentional multiline behavior. An explicit `false` does not change or undo that shell's history environment or startup configuration. Do not use history suppression as secret transport. See {ref}`history-hygiene` for shell behavior and {ref}`safety` before handling credentials. +For MCP calls, lightweight suppression is enabled by default. The {ref}`configuration ` setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` controls only omitted MCP `suppress_history` arguments, and an explicit `suppress_history` value wins. Direct Python calls default to `False`. `suppress_history=false` permits intentional multiline input. + +Suppression is best effort: {tooliconl}`run-command` prefixes one space to the grouped event that carries your single-line command, but the existing shell must be configured to ignore space-prefixed commands. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input because one prefix cannot protect multiple shell events. This control does not change the shell's environment or startup configuration, clear its memory or pane scrollback, or hide the command from other observers. See {ref}`history-hygiene` for shell behavior and {ref}`safety` before handling credentials. **Example:** diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index 101a8c3d..c2cf7927 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -11,9 +11,11 @@ container — create one before creating windows or panes. **Side effects:** Creates a new tmux session with one window and one pane. -For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`create-session` copies and merges supported shell-history controls into the tmux session environment, so they reach the initial pane and future panes in that session. An explicit `false` prevents new controls but does not remove compatible values already supplied in `environment`. Shell startup files can override the controls, and suppression does not remove terminal output or other traces; see {ref}`history-hygiene` and {ref}`safety`. +`suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. -The history policy only copies and merges environment values; it does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. +Set it to `true` and {tooliconl}`create-session` copies and merges best-effort no-disk history controls into the tmux session environment. They reach the initial pane and future panes in that session. The shell can retain in-memory history, and a startup file can override these controls after the process starts. + +The history policy does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Example:** diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md index 09396b88..3b0af2ac 100644 --- a/docs/tools/session/create-window.md +++ b/docs/tools/session/create-window.md @@ -7,9 +7,11 @@ **Side effects:** Creates a new window. Attaches to it if `attach` is true. -For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`create-window` adds an environment that applies to only the spawned process; it does not change the tmux session environment, and future windows or panes do not inherit it. An explicit `false` prevents new controls but does not remove controls inherited from the session environment. Shell startup files can override the controls; see {ref}`history-hygiene` and {ref}`safety`. +`suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. -The history policy only copies and merges environment values; it does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. +Set it to `true` and {tooliconl}`create-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so future windows and panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. + +The history policy does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Example:** diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md index 91ecd1a7..2037e512 100644 --- a/docs/tools/window/split-window.md +++ b/docs/tools/window/split-window.md @@ -8,9 +8,11 @@ window. **Side effects:** Creates a new pane by splitting an existing one. -For MCP calls, an omitted `suppress_history` follows the startup default in {ref}`configuration`, and an explicit `true` or `false` wins. Direct Python calls default to `False`. When suppression is effective, {tooliconl}`split-window` adds an environment that applies to only the spawned process; it does not change the tmux session environment, and later panes do not inherit it. An explicit `false` prevents new controls but does not remove controls inherited from the session environment. Shell startup files can override the controls; see {ref}`history-hygiene` and {ref}`safety`. +`suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. -The history policy only copies and merges environment values; it does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the suppression policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. +Set it to `true` and {tooliconl}`split-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so later panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. + +The history policy does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Example:** diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 800e8751..52369cba 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -78,11 +78,15 @@ However, they reset when the tmux **server** restarts. Do not cache pane IDs acr ## Shell-history suppression is best effort -The `suppress_history` argument reduces one history-persistence path; it does not make terminal input secret. {tooliconl}`run-command` prepends one ASCII space to the grouped event that carries the caller's single-line command. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input; set `suppress_history=false` for intentional multiline input. For [Bash 5.3](https://github.com/tianon/mirror-bash/blob/bash-5.3/doc/bashref.texi#L7274-L7282), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. +Most authored commands need no extra option: `suppress_history` is enabled by default for omitted MCP calls to {tooliconl}`run-command`. The tool prepends one ASCII space to the grouped event that carries your single-line command. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input; set `suppress_history=false` for intentional multiline input. {envvar}`LIBTMUX_SUPPRESS_HISTORY` controls this omitted MCP default, while an explicit argument wins. Direct Python calls still default to `False`. + +The prefix reduces one history-persistence path; it does not make terminal input private. For [Bash 5.3](https://github.com/tianon/mirror-bash/blob/bash-5.3/doc/bashref.texi#L7274-L7282), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. [Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) requires `HIST_IGNORE_SPACE` for the same prefix convention, and an ignored event can linger in internal history until the next event. [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) normally keeps a leading-space command off disk but leaves it recallable until the next command. A custom [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) function takes over that decision and can store leading-space commands, while [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) can strip leading spaces from pasted text. Do not treat the prefix convention as protection for paste input. -The startup setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` also supplies the omitted default for {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane`. Those spawn tools copy and merge an empty `HISTFILE`, Bash's `HISTCONTROL`, and private-history variables into the new process environment. The result is deliberately described as best effort: +For a process you are about to spawn, use the separate `suppress_persistent_history=true` option only when you want stronger best-effort no-disk controls. It defaults to `false` for both MCP and direct Python calls and never inherits {envvar}`LIBTMUX_SUPPRESS_HISTORY`. {tooliconl}`create-session` stores the controls in the new tmux session environment, so they reach the initial pane and future panes in that session. {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` apply them to only the process that each call starts; they do not change the tmux session environment. + +When enabled, the spawn tools copy and merge an empty `HISTFILE`, Bash's `HISTCONTROL`, and Fish private-history variables into the new environment. The result remains best effort: | Shell | Spawn controls | Remaining caveat | |-------|----------------|------------------| @@ -90,9 +94,9 @@ The startup setting {envvar}`LIBTMUX_SUPPRESS_HISTORY` also supplies the omitted | Zsh | Empty `HISTFILE` | The interactive process can retain in-memory history. | | Fish | Empty `fish_history`; non-empty `fish_private_mode` | The interactive process can retain in-memory history. | -Startup files can override any of these environment values after the process begins, so the shell can resume writing history to disk. An explicit `suppress_history=false` prevents the tool from adding controls, but it cannot erase controls already inherited from a tmux session, parent environment, or startup file. +Startup files can override any of these environment values after the process begins, so the shell can resume writing history to disk. Leaving `suppress_persistent_history=false` adds no controls, but it cannot remove inherited, session, or startup-file controls. The process can still receive history settings from the tmux server or session environment, your supplied `environment`, or its startup files. -{tooliconl}`send-keys` and {tooliconl}`send-keys-batch` do not inherit `LIBTMUX_SUPPRESS_HISTORY`; their `suppress_history` values remain explicit and default to `false`. Enabling it adds one leading space, so do not use it for control keys such as `C-c`, TUI input, or partial text unless that byte is intentional. In a batch, choose suppression separately for each operation. Paste tools have no suppression argument: {toolref}`paste-text` and {toolref}`paste-buffer` add no history prefix, but the active program can still interpret the paste or its whitespace. See {ref}`safety` for pane output, capture, process, transcript, hook, and log surfaces that remain visible. +Raw and TUI input stays outside both defaults. {tooliconl}`send-keys` and {tooliconl}`send-keys-batch` do not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`; their `suppress_history` values remain explicit and default to `false`. Enabling it adds one leading space, so do not use it for control keys such as `C-c`, TUI input, or partial text unless that byte is intentional. In a batch, choose suppression separately for each operation. Paste tools have no suppression argument: {toolref}`paste-text` and {toolref}`paste-buffer` add no history prefix, but the active program can still interpret the paste or its whitespace. See {ref}`safety` for pane output, capture, process, transcript, hook, and log surfaces that remain visible. ## Gemini CLI injects `wait_for_previous` into tool arguments diff --git a/docs/topics/logging.md b/docs/topics/logging.md index e787d5a8..a4bcd01f 100644 --- a/docs/topics/logging.md +++ b/docs/topics/logging.md @@ -59,6 +59,10 @@ their log pane (e.g. Claude Desktop's "MCP server logs" panel, or server name (``libtmux-mcp``), level, and the log message — but not the Python logger name, which the protocol doesn't model. +## History controls stay observable + +`suppress_history` and `suppress_persistent_history` affect shell-history behavior only. They do not disable audit logging and do not clear pane echo or scrollback. The audit logger still summarizes the call's arguments and outcome, other library or application loggers keep their own behavior, and an MCP client can still retain the original request and response. See {ref}`safety` for every observation boundary that remains outside history suppression. + ```{tip} If a tool call has no user-visible error or side effect, the ``libtmux_mcp.audit`` log shows the invocation and whether it returned or raised, not the tool's return value. Use the MCP response and current tmux state to determine what the tool returned or changed. ``` diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 571b44b2..8a8d2c04 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -120,7 +120,7 @@ These can execute anything the pane's shell accepts. There is no payload validat ### History suppression is not secret transport -Best-effort history suppression asks a shell not to persist selected commands in its disk history. It does not hide the command from other observation surfaces: +`suppress_history` on {tooliconl}`run-command` asks the current shell not to persist one space-prefixed command event. `suppress_persistent_history=true` on the four spawn tools adds best-effort no-disk controls to a new environment. Shell behavior and startup files can defeat either request. History suppression does not isolate the process, does not clear in-memory history or scrollback, and does not hide the command from other observation surfaces: - **pane echo and scrollback:** the terminal can display input, tmux can retain it in pane history, and an attached terminal can keep its own scrollback. - **capture tools and piping:** {tooliconl}`capture-pane`, {tooliconl}`capture-since`, {tooliconl}`snapshot-pane`, {tooliconl}`search-panes`, and {tooliconl}`pipe-pane` can return or route displayed and retained text. diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 0e29a85f..3142bff2 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -48,25 +48,42 @@ def test_topic_docs_do_not_overclaim_runtime_features( assert forbidden_text not in text -def test_configuration_documents_history_default_precedence_and_restart( +def test_configuration_documents_command_history_default_and_restart( docs_dir: pathlib.Path, ) -> None: - """History configuration names its scope, precedence, and lifecycle.""" + """The startup setting controls only omitted MCP run-command arguments.""" text = (docs_dir / "configuration.md").read_text(encoding="utf-8") assert "```{envvar} LIBTMUX_SUPPRESS_HISTORY" in text - assert "**Default:** `0`" in text - assert "Unset and `0` disable suppression; `1` enables it" in text + assert "**Default:** `1` (enabled)" in text + assert "Unset and `1` enable suppression; `0` disables it" in text assert "Any other value fails server startup" in text assert "LIBTMUX_SUPPRESS_HISTORY must be unset, '0', or '1'" in text + assert ( + "applies only when an MCP caller omits `suppress_history` from " + "{tooliconl}`run-command`" + ) in text assert "explicit `suppress_history` value wins" in text - assert "restart the MCP server" in text assert "prefixes one space" in text - assert "copies and merges" in text - assert "does not remove controls that the target process already inherits" in text + assert "set `suppress_history=false` for intentional multiline input" in text + assert "Direct Python calls default to `False`" in text + assert "Restart the MCP server only after changing this startup setting" in text + + +def test_configuration_separates_spawn_persistent_history_control( + docs_dir: pathlib.Path, +) -> None: + """Spawn history controls stay explicit and independent of startup.""" + text = (docs_dir / "configuration.md").read_text(encoding="utf-8") + + assert "`suppress_persistent_history`" in text + assert "defaults to `false` for MCP and direct Python calls" in text + assert "never inherits this startup setting" in text + assert "Setting it to `true` copies and merges" in text + assert "Leaving it `false` adds no history controls" in text + assert "cannot remove inherited, session, or startup-file controls" in text assert "without including the conflicting value" in text for tool in ( - "run-command", "create-session", "create-window", "split-window", @@ -92,8 +109,14 @@ def test_history_gotcha_documents_shell_limits_and_raw_input_boundary( assert "fish_should_add_to_history" in text assert "bracketed paste" in text assert "recallable until the next command" in text + assert "enabled by default for omitted MCP calls" in text + assert "`suppress_persistent_history=true`" in text + assert "defaults to `false`" in text + assert "initial pane and future panes in that session" in text + assert "only the process that each call starts" in text + assert "cannot remove inherited, session, or startup-file controls" in text assert "{tooliconl}`send-keys-batch`" in text - assert "do not inherit `LIBTMUX_SUPPRESS_HISTORY`" in text + assert "do not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`" in text assert "control keys such as `C-c`, TUI input, or partial text" in text assert "Paste tools have no suppression argument" in text assert "github.com/tianon/mirror-bash/blob/bash-5.3" in text @@ -134,12 +157,18 @@ def test_spawn_tool_pages_document_history_environment_scope( """Each spawn page says how far its history environment propagates.""" text = (docs_dir / relative_path).read_text(encoding="utf-8") - assert "`suppress_history`" in text + assert "`suppress_persistent_history`" in text assert required_scope in text - assert "startup files" in text - assert "Direct Python calls default to `False`" in text + assert "defaults to `false` for MCP and direct Python calls" in text + assert "does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`" in text + assert "Leave it `false` to add no history controls" in text + assert "cannot remove inherited, session, or startup-file controls" in text + assert "in-memory history" in text + assert "startup file can override" in text assert "does not rewrite command text" in text assert f"{{tooliconl}}`{tool_slug}`" in text + assert "`suppress_history`" not in text + assert "follows the startup default" not in text def test_run_command_page_documents_effective_history_policy( @@ -148,9 +177,12 @@ def test_run_command_page_documents_effective_history_policy( """The semantic command page distinguishes startup and explicit policy.""" text = (docs_dir / "tools" / "pane" / "run-command.md").read_text(encoding="utf-8") - assert "`LIBTMUX_SUPPRESS_HISTORY`" in text + assert "{envvar}`LIBTMUX_SUPPRESS_HISTORY`" in text + assert "enabled by default" in text + assert "only omitted MCP `suppress_history` arguments" in text assert "explicit `suppress_history` value wins" in text assert "Direct Python calls default to `False`" in text + assert "`suppress_history=false` permits intentional multiline input" in text assert "existing shell" in text assert "best effort" in text @@ -173,6 +205,10 @@ def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( assert surface in text assert "credential references" in text assert "literal credentials" in text + assert "`suppress_history`" in text + assert "`suppress_persistent_history=true`" in text + assert "does not isolate the process" in text + assert "does not clear in-memory history or scrollback" in text assert "{tooliconl}`pipe-pane`" in text assert "attached terminal" in text assert "application logs" in text @@ -205,6 +241,10 @@ def test_logging_docs_describe_audit_outcomes_without_return_values( "the ``libtmux_mcp.audit`` log shows the invocation and whether it " "returned or raised, not the tool's return value." ) in text + assert "`suppress_history` and `suppress_persistent_history`" in text + assert "do not disable audit logging" in text + assert "do not clear pane echo or scrollback" in text + assert "MCP client can still retain the original request and response" in text def test_unreleased_changelog_documents_history_suppression( @@ -227,7 +267,11 @@ def test_unreleased_changelog_documents_history_suppression( assert "**Best-effort shell-history suppression**" in unreleased assert "{tooliconl}`run-command`" in unreleased assert "{tooliconl}`create-session`" in unreleased + assert "{tooliconl}`create-window`" in unreleased + assert "{tooliconl}`split-window`" in unreleased + assert "{tooliconl}`respawn-pane`" in unreleased assert "{envvar}`LIBTMUX_SUPPRESS_HISTORY`" in unreleased + assert "`suppress_persistent_history=false`" in unreleased assert "{toolref}`send-keys-batch`" in unreleased assert "{toolref}`paste-text`" in unreleased assert "{ref}`configuration`" in unreleased From bae6969214261d539fed9ef0985951e957508d1c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 17:15:02 -0500 Subject: [PATCH 13/20] mcp(docs[history]): Clarify compatibility why: Keep spawn and discovery wording accurate after the default flip. what: - Distinguish command text from tmux environment arguments - Explain Python table and MCP discovery defaults - Lock the clarified wording in documentation tests --- docs/tools/pane/respawn-pane.md | 2 +- docs/tools/pane/run-command.md | 4 ++++ docs/tools/server/create-session.md | 2 +- docs/tools/session/create-window.md | 2 +- docs/tools/window/split-window.md | 2 +- tests/docs/test_topic_contracts.py | 18 +++++++++++++++++- 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 8b06fa6b..9fa3ae62 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -23,7 +23,7 @@ point of the tool. `pane_pid` updates to the new process. Set it to `true` and {tooliconl}`respawn-pane` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment or affect later panes. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -The history policy does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Tip:** Call {tooliconl}`get-pane-info` first if you need to capture `pane_current_command` before respawn — the new process loses its argv. diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md index aee99444..51692a57 100644 --- a/docs/tools/pane/run-command.md +++ b/docs/tools/pane/run-command.md @@ -43,5 +43,9 @@ Response: } ``` +```{note} +The generated parameter table below reflects the direct Python signature, so it shows `suppress_history=False`. MCP `tools/list` advertises the effective suppression default instead: `true` unless {envvar}`LIBTMUX_SUPPRESS_HISTORY` is `0`. An MCP call that omits the argument uses that advertised default. +``` + ```{fastmcp-tool-input} pane_tools.run_command ``` diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index c2cf7927..4e1e2626 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -15,7 +15,7 @@ container — create one before creating windows or panes. Set it to `true` and {tooliconl}`create-session` copies and merges best-effort no-disk history controls into the tmux session environment. They reach the initial pane and future panes in that session. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -The history policy does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Example:** diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md index 3b0af2ac..584fdda0 100644 --- a/docs/tools/session/create-window.md +++ b/docs/tools/session/create-window.md @@ -11,7 +11,7 @@ Set it to `true` and {tooliconl}`create-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so future windows and panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -The history policy does not rewrite command text or tmux launch arguments. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Example:** diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md index 2037e512..fca105b4 100644 --- a/docs/tools/window/split-window.md +++ b/docs/tools/window/split-window.md @@ -12,7 +12,7 @@ window. Set it to `true` and {tooliconl}`split-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so later panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. -The history policy does not rewrite command text. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. +When you enable it, tmux environment arguments are added, but the spawned process command text is not prefixed or rewritten. The `shell` text is passed through unchanged. If you also pass `environment`, any history-control values must agree with the policy. A conflict fails the call, names the variable without including the conflicting value, and is never retried without suppression. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for output, scrollback, process, transcript, hook, and logging boundaries. **Example:** diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 3142bff2..39b9a615 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -165,10 +165,12 @@ def test_spawn_tool_pages_document_history_environment_scope( assert "cannot remove inherited, session, or startup-file controls" in text assert "in-memory history" in text assert "startup file can override" in text - assert "does not rewrite command text" in text + assert "tmux environment arguments are added" in text + assert "spawned process command text is not prefixed or rewritten" in text assert f"{{tooliconl}}`{tool_slug}`" in text assert "`suppress_history`" not in text assert "follows the startup default" not in text + assert "does not rewrite command text or tmux launch arguments" not in text def test_run_command_page_documents_effective_history_policy( @@ -185,6 +187,12 @@ def test_run_command_page_documents_effective_history_policy( assert "`suppress_history=false` permits intentional multiline input" in text assert "existing shell" in text assert "best effort" in text + assert ( + "generated parameter table below reflects the direct Python signature" in text + ) + assert "`suppress_history=False`" in text + assert "MCP `tools/list` advertises the effective suppression default" in text + assert "`true` unless {envvar}`LIBTMUX_SUPPRESS_HISTORY` is `0`" in text def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( @@ -264,6 +272,14 @@ def test_unreleased_changelog_documents_history_suppression( ) unreleased = "\n".join(after_placeholder_lines[:next_release_index]) + breaking_index = unreleased.index("### Breaking changes") + whats_new_index = unreleased.index("### What's new") + + assert breaking_index < whats_new_index + assert "**MCP command-history suppression defaults on**" in unreleased + assert "MCP calls that omit `suppress_history`" in unreleased + assert "pass `suppress_history=false` for that call" in unreleased + assert "set {envvar}`LIBTMUX_SUPPRESS_HISTORY` to `0`" in unreleased assert "**Best-effort shell-history suppression**" in unreleased assert "{tooliconl}`run-command`" in unreleased assert "{tooliconl}`create-session`" in unreleased From 22289ee9ada8b3fd9af87b27644820c0f5e4c668 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 18:05:33 -0500 Subject: [PATCH 14/20] mcp(fix[prompt]): Preserve multiline recipes why: Keep the public run-and-wait prompt executable when a command has line breaks and the MCP history-suppression default is enabled. what: - Render an explicit suppression opt-out for LF and CR commands - Disclose possible shell-history persistence in multiline recipes - Cover real and escaped line breaks without changing single-line output --- src/libtmux_mcp/prompts/recipes.py | 12 ++++++++++-- tests/test_prompts.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/libtmux_mcp/prompts/recipes.py b/src/libtmux_mcp/prompts/recipes.py index b45e06a4..e1dc0b16 100644 --- a/src/libtmux_mcp/prompts/recipes.py +++ b/src/libtmux_mcp/prompts/recipes.py @@ -34,6 +34,14 @@ def run_and_wait( timeout : float Maximum seconds to wait for command completion. Default 60. """ + multiline = "\n" in command or "\r" in command + history_argument = " suppress_history=False,\n" if multiline else "" + history_warning = ( + "\n\nThis multiline command disables best-effort shell-history " + "suppression and may be recorded by the shell." + if multiline + else "" + ) return f"""Run this shell command in tmux pane {pane_id}, wait until it finishes, and inspect the typed result: @@ -41,10 +49,10 @@ def run_and_wait( result = run_command( pane_id={pane_id!r}, command={command!r}, - timeout={timeout}, +{history_argument} timeout={timeout}, max_lines=100, ) -``` +```{history_warning} Use `result.exit_status`, `result.timed_out`, and `result.output` to decide what happened. Do NOT use a `send_keys` + `capture_pane` diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 04ee5c7d..43947106 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import asyncio import pytest @@ -75,8 +76,6 @@ def test_run_and_wait_does_not_render_manual_channel_recipe() -> None: def test_run_and_wait_handles_quoted_commands() -> None: """Single quotes in the command don't corrupt the rendered call.""" - import ast - from libtmux_mcp.prompts.recipes import run_and_wait text = run_and_wait(command="python -c 'print(1)'", pane_id="%1") @@ -88,6 +87,31 @@ def test_run_and_wait_handles_quoted_commands() -> None: parsed = ast.literal_eval(payload) assert isinstance(parsed, str) assert "python -c 'print(1)'" in parsed + assert "suppress_history=" not in text + + +@pytest.mark.parametrize( + ("command", "suppression_disabled"), + [ + pytest.param("printf first\nprintf second", True, id="line-feed"), + pytest.param("printf first\rprintf second", True, id="carriage-return"), + pytest.param(r"printf first\nprintf second", False, id="escaped-line-feed"), + ], +) +def test_run_and_wait_disables_suppression_for_multiline_commands( + command: str, + suppression_disabled: bool, +) -> None: + """Multiline recipes remain executable and disclose history behavior.""" + from libtmux_mcp.prompts.recipes import run_and_wait + + text = run_and_wait(command=command, pane_id="%1") + + sample = text.split("```python\n", 1)[1].split("\n```", 1)[0] + ast.parse(sample) + assert f"command={command!r}," in text + assert ("suppress_history=False," in text) is suppression_disabled + assert ("may be recorded by the shell" in text) is suppression_disabled def test_interrupt_gracefully_does_not_escalate() -> None: From 1c8ef962c9154205f2759157a1943e14d00ae961 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 18:31:35 -0500 Subject: [PATCH 15/20] mcp(docs[safety]): Disclose env visibility why: Spawn environment values cross process-observable surfaces even when the MCP audit record redacts them. what: - Warn create_window and split_window callers in their MCP schema text - Explain tmux argv, child environment, and session-state boundaries - Contract-test client-visible descriptions and central safety guidance --- docs/topics/safety.md | 2 +- src/libtmux_mcp/tools/session_tools.py | 6 +++++- src/libtmux_mcp/tools/window_tools.py | 6 +++++- tests/docs/test_topic_contracts.py | 19 +++++++++++++++++ tests/test_spawn_tools_history.py | 29 ++++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 8a8d2c04..c1faea82 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -125,7 +125,7 @@ These can execute anything the pane's shell accepts. There is no payload validat - **pane echo and scrollback:** the terminal can display input, tmux can retain it in pane history, and an attached terminal can keep its own scrollback. - **capture tools and piping:** {tooliconl}`capture-pane`, {tooliconl}`capture-since`, {tooliconl}`snapshot-pane`, {tooliconl}`search-panes`, and {tooliconl}`pipe-pane` can return or route displayed and retained text. - **hooks:** configured tmux hooks, including state visible through {tooliconl}`show-hooks`, and shell instrumentation can observe process or pane activity independently of shell history. -- **process visibility:** command arguments, launch strings, and environment values may be visible to host process inspection while a process starts or runs. +- **process visibility:** command arguments and launch strings can appear in the tmux client argv. Environment values passed to {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` can also remain in a child process environment; {toolref}`create-session` retains them in tmux session state for future panes. MCP audit redaction does not hide any of these surfaces from host process or tmux environment inspection. - **MCP client transcripts:** clients can retain the original request and response outside the server's control. - **logs:** `libtmux_mcp.audit` records redacted arguments and whether the call succeeded or raised; it does not contain tool return values. Redaction applies only to these audit records and does not rewrite separate records emitted by libtmux, FastMCP, shells, or MCP clients. libtmux DEBUG or error records may contain shell-joined tmux arguments, while MCP client request logs and application logs remain outside the server's guarantee. diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 0bdc8e69..7805c3ff 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -144,7 +144,11 @@ def create_window( tmux socket name. Defaults to LIBTMUX_SOCKET env var. environment : dict or str, optional Per-process environment as a mapping or JSON object string. Values do - not modify the tmux session environment. + not modify the tmux session environment. Each item becomes a tmux + ``-e`` launch option. Values may be visible to host process inspection + in the tmux client argv during launch and in the child environment + afterward; MCP audit redaction does not hide either surface. Pass + credential references, not literal credentials. suppress_persistent_history : bool Whether to suppress persistent history for the spawned shell. Defaults to False for MCP and direct Python calls. This per-call option does not diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 1fd593ab..0a8461b3 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -197,7 +197,11 @@ def split_window( tmux socket name. environment : dict or str, optional Per-process environment as a mapping or JSON object string. Values do - not modify the tmux session environment. + not modify the tmux session environment. Each item becomes a tmux + ``-e`` launch option. Values may be visible to host process inspection + in the tmux client argv during launch and in the child environment + afterward; MCP audit redaction does not hide either surface. Pass + credential references, not literal credentials. suppress_persistent_history : bool Whether to suppress persistent history for the spawned shell. Defaults to False for MCP and direct Python calls. This per-call option does not diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 39b9a615..7031efd3 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -227,6 +227,25 @@ def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( assert "libtmux, FastMCP, shells, or MCP clients" in text assert "A JSON string is redacted as one scalar digest" in text assert "dict-shaped sensitive key `environment`" not in text + process_visibility = next( + line + for line in text.splitlines() + if line.startswith("- **process visibility:**") + ) + for tool in ( + "create-session", + "create-window", + "split-window", + "respawn-pane", + ): + assert f"{{toolref}}`{tool}`" in process_visibility + for boundary in ( + "tmux client argv", + "child process environment", + "tmux session state", + "MCP audit redaction", + ): + assert boundary in process_visibility def test_respawn_page_distinguishes_environment_audit_shapes( diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py index 04d9a673..4e30c051 100644 --- a/tests/test_spawn_tools_history.py +++ b/tests/test_spawn_tools_history.py @@ -123,6 +123,35 @@ def test_spawn_environment_schemas_are_client_compatible() -> None: assert suppress_persistent_history["default"] is False +def test_process_scoped_spawn_environment_schemas_disclose_visibility() -> None: + """Process-scoped tools disclose environment observation surfaces.""" + from libtmux_mcp.tools import register_tools + + mcp = FastMCP("spawn-environment-disclosure") + register_tools(mcp) + + async def _list_tools() -> dict[str, t.Any]: + async with Client(mcp) as client: + return {tool.name: tool for tool in await client.list_tools()} + + tools = asyncio.run(_list_tools()) + + for name in ("create_window", "split_window"): + description = tools[name].inputSchema["properties"]["environment"][ + "description" + ] + for fragment in ( + "-e", + "tmux client", + "child environment", + "host process inspection", + "MCP audit redaction", + "credential references", + "not literal credentials", + ): + assert fragment in description + + def _assert_value_free_spawn_conflict(call: t.Callable[[], t.Any], name: str) -> None: """Assert one spawn call rejects a policy conflict without its value.""" from fastmcp.exceptions import ToolError From efe1428392b9a893d673adfc862fa51a4dbcd33c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 19:10:47 -0500 Subject: [PATCH 16/20] mcp(docs[create_session]): Warn on env scope why: Session environment values persist beyond launch and remain visible outside the redacted MCP audit record. what: - Add client-visible argv, session-state, and child-environment guidance - Add an early credential warning to the create-session task page - Contract-test the schema disclosure and task-page placement --- docs/tools/server/create-session.md | 4 ++++ src/libtmux_mcp/tools/server_tools.py | 8 +++++++- tests/docs/test_topic_contracts.py | 21 +++++++++++++++++++++ tests/test_spawn_tools_history.py | 23 +++++++++++++++++++++-- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index 4e1e2626..9bba9228 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -11,6 +11,10 @@ container — create one before creating windows or panes. **Side effects:** Creates a new tmux session with one window and one pane. +**Do not pass credentials directly in `environment`.** Values persist in the +new session and reach its initial pane and future panes. Pass credential +references instead; see {ref}`safety` for details. + `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. Set it to `true` and {tooliconl}`create-session` copies and merges best-effort no-disk history controls into the tmux session environment. They reach the initial pane and future panes in that session. The shell can retain in-memory history, and a startup file can override these controls after the process starts. diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index d2938240..d9ab55af 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -100,7 +100,13 @@ def create_session( Environment variables to store in the session environment. Accepts either a dict of env vars or a JSON-serialized string of the same — the latter is the cursor-composer-1 workaround described in - :func:`libtmux_mcp._utils._coerce_dict_arg`. + :func:`libtmux_mcp._utils._coerce_dict_arg`. Each item appears in the + tmux client argv as one ``-eKEY=VALUE`` element and may be visible to + host process inspection during launch. tmux retains the values in + tmux session state, where ``show-environment`` can reveal them. They reach + the initial and future child environments unless a later spawn + overrides them. MCP audit redaction does not hide these surfaces. Pass + credential references, not literal credentials. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. suppress_persistent_history : bool diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 7031efd3..06439400 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -173,6 +173,27 @@ def test_spawn_tool_pages_document_history_environment_scope( assert "does not rewrite command text or tmux launch arguments" not in text +def test_create_session_page_warns_against_literal_credentials( + docs_dir: pathlib.Path, +) -> None: + """Create-session gives an early warning and keeps generated detail.""" + text = (docs_dir / "tools" / "server" / "create-session.md").read_text( + encoding="utf-8" + ) + caution = "**Do not pass credentials directly in `environment`.**" + caution_index = text.index(caution) + history_index = text.index("`suppress_persistent_history`") + caution_block = text[caution_index:history_index] + normalized = " ".join(caution_block.split()) + + assert text.index("**Side effects:**") < caution_index < history_index + assert "Values persist in the new session" in normalized + assert "initial pane and future panes" in normalized + assert "Pass credential references instead" in normalized + assert "{ref}`safety`" in caution_block + assert "```{fastmcp-tool-input} server_tools.create_session" in text + + def test_run_command_page_documents_effective_history_policy( docs_dir: pathlib.Path, ) -> None: diff --git a/tests/test_spawn_tools_history.py b/tests/test_spawn_tools_history.py index 4e30c051..b6387bf5 100644 --- a/tests/test_spawn_tools_history.py +++ b/tests/test_spawn_tools_history.py @@ -123,8 +123,8 @@ def test_spawn_environment_schemas_are_client_compatible() -> None: assert suppress_persistent_history["default"] is False -def test_process_scoped_spawn_environment_schemas_disclose_visibility() -> None: - """Process-scoped tools disclose environment observation surfaces.""" +def test_spawn_environment_schemas_disclose_visibility() -> None: + """Spawn tools disclose their distinct environment observation surfaces.""" from libtmux_mcp.tools import register_tools mcp = FastMCP("spawn-environment-disclosure") @@ -151,6 +151,25 @@ async def _list_tools() -> dict[str, t.Any]: ): assert fragment in description + session_description = tools["create_session"].inputSchema["properties"][ + "environment" + ]["description"] + for fragment in ( + "dict", + "JSON", + "string", + "tmux client argv", + "initial and future child environments", + "tmux session state", + "show-environment", + "later spawn", + "overrides", + "MCP audit redaction", + "credential references", + "not literal credentials", + ): + assert fragment in session_description + def _assert_value_free_spawn_conflict(call: t.Callable[[], t.Any], name: str) -> None: """Assert one spawn call rejects a policy conflict without its value.""" From 642277c07fe4698cd00773d1c253e5f482d41084 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 19:15:33 -0500 Subject: [PATCH 17/20] mcp(docs[roles]): Compact dense tool lists why: Repeated safety icons fragment dense explanatory sequences and conflict with the documented role convention. what: - Use toolref for the three reviewed dense tool sequences - Bind role contracts to the exact paragraphs and safety bullet --- docs/configuration.md | 2 +- docs/topics/gotchas.md | 2 +- docs/topics/safety.md | 2 +- tests/docs/test_topic_contracts.py | 36 ++++++++++++++++++++++++++++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f1a82144..2b2aa329 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,7 +52,7 @@ Unset and `1` enable suppression; `0` disables it. Any other value fails server {toolref}`run-command` prefixes one space to the grouped event that carries your single-line command. When suppression is effective, a command containing a carriage return or line feed fails before tmux receives input; set `suppress_history=false` for intentional multiline input. -Process creation uses a separate control. {tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` expose `suppress_persistent_history`, which defaults to `false` for MCP and direct Python calls and never inherits this startup setting. Setting it to `true` copies and merges best-effort no-disk history controls into the spawned environment. A conflicting caller-supplied history value fails the call, names the environment variable without including the conflicting value, and is never retried without suppression. +Process creation uses a separate control. {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` expose `suppress_persistent_history`, which defaults to `false` for MCP and direct Python calls and never inherits this startup setting. Setting it to `true` copies and merges best-effort no-disk history controls into the spawned environment. A conflicting caller-supplied history value fails the call, names the environment variable without including the conflicting value, and is never retried without suppression. Leaving it `false` adds no history controls. That choice cannot remove inherited, session, or startup-file controls; the process can still receive them from tmux, your supplied `environment`, or a shell startup file. The startup default never changes the raw-input behavior of {toolref}`send-keys`, {toolref}`send-keys-batch`, {toolref}`paste-text`, or {toolref}`paste-buffer`. diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 52369cba..e60f03e1 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -84,7 +84,7 @@ The prefix reduces one history-persistence path; it does not make terminal input [Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) requires `HIST_IGNORE_SPACE` for the same prefix convention, and an ignored event can linger in internal history until the next event. [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) normally keeps a leading-space command off disk but leaves it recallable until the next command. A custom [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) function takes over that decision and can store leading-space commands, while [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) can strip leading spaces from pasted text. Do not treat the prefix convention as protection for paste input. -For a process you are about to spawn, use the separate `suppress_persistent_history=true` option only when you want stronger best-effort no-disk controls. It defaults to `false` for both MCP and direct Python calls and never inherits {envvar}`LIBTMUX_SUPPRESS_HISTORY`. {tooliconl}`create-session` stores the controls in the new tmux session environment, so they reach the initial pane and future panes in that session. {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` apply them to only the process that each call starts; they do not change the tmux session environment. +For a process you are about to spawn, use the separate `suppress_persistent_history=true` option only when you want stronger best-effort no-disk controls. It defaults to `false` for both MCP and direct Python calls and never inherits {envvar}`LIBTMUX_SUPPRESS_HISTORY`. {toolref}`create-session` stores the controls in the new tmux session environment, so they reach the initial pane and future panes in that session. {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` apply them to only the process that each call starts; they do not change the tmux session environment. When enabled, the spawn tools copy and merge an empty `HISTFILE`, Bash's `HISTCONTROL`, and Fish private-history variables into the new environment. The result remains best effort: diff --git a/docs/topics/safety.md b/docs/topics/safety.md index c1faea82..f5c94f44 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -123,7 +123,7 @@ These can execute anything the pane's shell accepts. There is no payload validat `suppress_history` on {tooliconl}`run-command` asks the current shell not to persist one space-prefixed command event. `suppress_persistent_history=true` on the four spawn tools adds best-effort no-disk controls to a new environment. Shell behavior and startup files can defeat either request. History suppression does not isolate the process, does not clear in-memory history or scrollback, and does not hide the command from other observation surfaces: - **pane echo and scrollback:** the terminal can display input, tmux can retain it in pane history, and an attached terminal can keep its own scrollback. -- **capture tools and piping:** {tooliconl}`capture-pane`, {tooliconl}`capture-since`, {tooliconl}`snapshot-pane`, {tooliconl}`search-panes`, and {tooliconl}`pipe-pane` can return or route displayed and retained text. +- **capture tools and piping:** {toolref}`capture-pane`, {toolref}`capture-since`, {toolref}`snapshot-pane`, {toolref}`search-panes`, and {toolref}`pipe-pane` can return or route displayed and retained text. - **hooks:** configured tmux hooks, including state visible through {tooliconl}`show-hooks`, and shell instrumentation can observe process or pane activity independently of shell history. - **process visibility:** command arguments and launch strings can appear in the tmux client argv. Environment values passed to {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` can also remain in a child process environment; {toolref}`create-session` retains them in tmux session state for future panes. MCP audit redaction does not hide any of these surfaces from host process or tmux environment inspection. - **MCP client transcripts:** clients can retain the original request and response outside the server's control. diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 06439400..15ca731d 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -83,13 +83,19 @@ def test_configuration_separates_spawn_persistent_history_control( assert "Leaving it `false` adds no history controls" in text assert "cannot remove inherited, session, or startup-file controls" in text assert "without including the conflicting value" in text + spawn_control = next( + paragraph + for paragraph in text.split("\n\n") + if paragraph.startswith("Process creation uses a separate control") + ) for tool in ( "create-session", "create-window", "split-window", "respawn-pane", ): - assert f"{{tooliconl}}`{tool}`" in text + assert f"{{toolref}}`{tool}`" in spawn_control + assert f"{{tooliconl}}`{tool}`" not in spawn_control for tool in ("send-keys", "send-keys-batch", "paste-text", "paste-buffer"): assert f"{{toolref}}`{tool}`" in text @@ -121,6 +127,19 @@ def test_history_gotcha_documents_shell_limits_and_raw_input_boundary( assert "Paste tools have no suppression argument" in text assert "github.com/tianon/mirror-bash/blob/bash-5.3" in text assert "the default for bash" not in text + spawn_scope = next( + paragraph + for paragraph in text.split("\n\n") + if paragraph.startswith("For a process you are about to spawn") + ) + for tool in ( + "create-session", + "create-window", + "split-window", + "respawn-pane", + ): + assert f"{{toolref}}`{tool}`" in spawn_scope + assert f"{{tooliconl}}`{tool}`" not in spawn_scope @pytest.mark.parametrize( @@ -238,7 +257,6 @@ def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( assert "`suppress_persistent_history=true`" in text assert "does not isolate the process" in text assert "does not clear in-memory history or scrollback" in text - assert "{tooliconl}`pipe-pane`" in text assert "attached terminal" in text assert "application logs" in text assert "shell-joined tmux arguments" in text @@ -248,6 +266,20 @@ def test_safety_docs_name_history_non_goals_and_secret_reference_guidance( assert "libtmux, FastMCP, shells, or MCP clients" in text assert "A JSON string is redacted as one scalar digest" in text assert "dict-shaped sensitive key `environment`" not in text + capture_visibility = next( + line + for line in text.splitlines() + if line.startswith("- **capture tools and piping:**") + ) + for tool in ( + "capture-pane", + "capture-since", + "snapshot-pane", + "search-panes", + "pipe-pane", + ): + assert f"{{toolref}}`{tool}`" in capture_visibility + assert f"{{tooliconl}}`{tool}`" not in capture_visibility process_visibility = next( line for line in text.splitlines() From 33ff61cd8d7147df66b8def88dd0ddf8a5b22794 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 19:28:58 -0500 Subject: [PATCH 18/20] mcp(test[history]): Scope changelog contract why: The changelog contract should validate each product feature without coupling assertions to unrelated unreleased entries. what: - Scope history-control assertions to their deliverable blocks - Cover the environment summary and linked safety guidance --- tests/docs/test_topic_contracts.py | 62 ++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 15ca731d..a0eb9708 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -327,10 +327,10 @@ def test_logging_docs_describe_audit_outcomes_without_return_values( assert "MCP client can still retain the original request and response" in text -def test_unreleased_changelog_documents_history_suppression( +def test_unreleased_changelog_summarizes_history_features( docs_dir: pathlib.Path, ) -> None: - """The unreleased changelog exposes the history-control deliverable.""" + """The unreleased changelog stays focused on product-level features.""" text = (docs_dir.parent / "CHANGES").read_text(encoding="utf-8") placeholder_end = ( "" @@ -346,20 +346,48 @@ def test_unreleased_changelog_documents_history_suppression( breaking_index = unreleased.index("### Breaking changes") whats_new_index = unreleased.index("### What's new") + history_heading = "**History controls for spawned shells**" + environment_heading = "**Per-process environments for windows and panes**" + breaking_entry = unreleased[:whats_new_index] + history_entry = unreleased.split(history_heading, maxsplit=1)[1].split( + environment_heading, maxsplit=1 + )[0] + environment_entry = unreleased.split(environment_heading, maxsplit=1)[1].split( + "### Documentation", maxsplit=1 + )[0] assert breaking_index < whats_new_index - assert "**MCP command-history suppression defaults on**" in unreleased - assert "MCP calls that omit `suppress_history`" in unreleased - assert "pass `suppress_history=false` for that call" in unreleased - assert "set {envvar}`LIBTMUX_SUPPRESS_HISTORY` to `0`" in unreleased - assert "**Best-effort shell-history suppression**" in unreleased - assert "{tooliconl}`run-command`" in unreleased - assert "{tooliconl}`create-session`" in unreleased - assert "{tooliconl}`create-window`" in unreleased - assert "{tooliconl}`split-window`" in unreleased - assert "{tooliconl}`respawn-pane`" in unreleased - assert "{envvar}`LIBTMUX_SUPPRESS_HISTORY`" in unreleased - assert "`suppress_persistent_history=false`" in unreleased - assert "{toolref}`send-keys-batch`" in unreleased - assert "{toolref}`paste-text`" in unreleased - assert "{ref}`configuration`" in unreleased + assert "**History suppression now defaults on for run commands**" in breaking_entry + assert "MCP clients now see the effective server default" in breaking_entry + assert "calls that omit the argument inherit it" in breaking_entry + assert ( + "while suppression is enabled, pass `suppress_history=false`" in breaking_entry + ) + assert "set {envvar}`LIBTMUX_SUPPRESS_HISTORY` to `0`" in breaking_entry + assert "{tooliconl}`run-command`" in breaking_entry + assert "{ref}`configuration`" in breaking_entry + assert "space-prefixed" not in breaking_entry + + for tool in ( + "create-session", + "create-window", + "split-window", + "respawn-pane", + ): + assert f"{{tooliconl}}`{tool}`" in history_entry + assert "`suppress_persistent_history=true`" in history_entry + assert "Session controls reach the initial and future panes" in history_entry + assert "{ref}`history-hygiene`" in history_entry + assert "{ref}`safety`" in history_entry + assert "space-prefixed" not in history_entry + + assert "{tooliconl}`create-window`" in environment_entry + assert "{tooliconl}`split-window`" in environment_entry + assert ( + "per-process `environment` mappings or JSON object strings" in environment_entry + ) + assert "without changing the tmux session environment" in environment_entry + assert "{tooliconl}`respawn-pane`" in environment_entry + assert "same JSON object form" in environment_entry + assert "credential references, not literal credentials" in environment_entry + assert "{ref}`safety`" in environment_entry From 7bbf68fdf4f9c11ecbc2e0488ac0d22fa4155079 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 20:12:51 -0500 Subject: [PATCH 19/20] mcp(docs[history]): Add suppression guide why: Give end users one path from default-on command history hygiene to stronger opt-in spawned-shell controls for Bash, Zsh, and Fish. what: - Add a dedicated topic and front-page workflow - Document multiline prompts and scoped spawn environments - Link affected tools and result models at point of use - Move the docs contract to the canonical topic --- docs/index.md | 26 +++++ docs/prompts.md | 12 ++- docs/tools/pane/respawn-pane.md | 2 +- docs/tools/pane/run-command.md | 7 +- docs/tools/server/create-session.md | 7 +- docs/tools/session/create-window.md | 24 ++++- docs/tools/window/split-window.md | 24 ++++- docs/topics/gotchas.md | 27 ++---- docs/topics/history-suppression.md | 143 ++++++++++++++++++++++++++++ docs/topics/index.md | 7 ++ docs/topics/safety.md | 2 +- tests/docs/test_topic_contracts.py | 49 +++++----- 12 files changed, 274 insertions(+), 56 deletions(-) create mode 100644 docs/topics/history-suppression.md diff --git a/docs/index.md b/docs/index.md index e735b4fe..48a0c0d7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -85,6 +85,32 @@ Tear down tmux objects. Not reversible. {toolref}`kill-session` · {toolref}`kill-window` · {toolref}`kill-pane` · {toolref}`kill-server` · {toolref}`call-destructive-tools-batch` +### Example: keep test runs out of persistent history + +libtmux-mcp provides best-effort history suppression for Bash, Zsh, and Fish. +MCP calls to {tooliconl}`run-command` use lightweight command suppression by +default. When you create a new shell, opt into stronger no-disk controls with +`suppress_persistent_history=true`. + +```{admonition} Prompt +:class: prompt + +Create a tmux session called "checks" with best-effort no-disk shell-history +controls, run `pytest -q` in its initial pane, and show me the result. +``` + +The agent calls {tooliconl}`create-session` with +`suppress_persistent_history=true`, reuses `active_pane_id` from the returned +{class}`~libtmux_mcp.models.SessionInfo`, and calls +{tooliconl}`run-command` without an override. The same spawn option on +{toolref}`create-window`, {toolref}`split-window`, or +{toolref}`respawn-pane` applies only to the process that call starts. + +These controls reduce history noise; they do not make commands secret. See +{ref}`history-suppression` for shell-specific behavior, +{ref}`configuration` for the server default, and {ref}`safety` for other +observation surfaces. + [Browse all tools →](tools/index) --- diff --git a/docs/prompts.md b/docs/prompts.md index 3a21e3a3..093237bf 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -58,9 +58,9 @@ for completion, and inspect exit status plus output. **Why use this instead of {tooliconl}`send-keys` + {tooliconl}`capture-pane` polling?** {tooliconl}`run-command` sends the command, waits through a private -tmux signal, captures tail-preserved output, and returns exit status -in one typed result. That removes the manual channel plumbing from -the common authored-command workflow. +tmux signal, captures tail-preserved output, and returns a +{class}`~libtmux_mcp.models.RunCommandResult`. That removes the manual +channel plumbing from the common authored-command workflow. ```{fastmcp-prompt-input} run_and_wait ``` @@ -90,6 +90,12 @@ a one-shot shell command, use `send_keys` or `send_keys_batch`, then observe later output with `capture_since`. ```` +Single-line renders omit `suppress_history`, so MCP calls use the server's +enabled-by-default setting. If `command` contains a carriage return or line +feed, the prompt instead renders `suppress_history=False` and warns that the +shell may record the multiline command. See {ref}`history-suppression` for +the Bash, Zsh, and Fish behavior behind both cases. + For custom shell composition that falls outside {tooliconl}`run-command`, compose ``tmux wait-for -S `` yourself and call {tooliconl}`wait-for-channel`. Keep that as the low-level escape hatch, diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 9fa3ae62..7142910f 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -73,7 +73,7 @@ time). Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, sha256_prefix}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details. -Response (PaneInfo): +Response ({class}`~libtmux_mcp.models.PaneInfo`): ```json5 { diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md index 51692a57..c2e669d9 100644 --- a/docs/tools/pane/run-command.md +++ b/docs/tools/pane/run-command.md @@ -3,8 +3,9 @@ ```{fastmcp-tool} pane_tools.run_command ``` -**Use when** you need to run a shell command in a pane and get a typed -result with exit status, timeout state, and captured pane output. +**Use when** you need to run a shell command in a pane and get a +{class}`~libtmux_mcp.models.RunCommandResult` with exit status, timeout state, +and captured pane output. **Avoid when** you need raw interactive key driving — use {tooliconl}`send-keys` or {tooliconl}`send-keys-batch` for TUIs, key @@ -29,7 +30,7 @@ Suppression is best effort: {tooliconl}`run-command` prefixes one space to the g } ``` -Response: +Response ({class}`~libtmux_mcp.models.RunCommandResult`): ```json { diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md index 9bba9228..bb65860b 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -12,8 +12,9 @@ container — create one before creating windows or panes. **Side effects:** Creates a new tmux session with one window and one pane. **Do not pass credentials directly in `environment`.** Values persist in the -new session and reach its initial pane and future panes. Pass credential -references instead; see {ref}`safety` for details. +new session, can be inspected with {tooliconl}`show-environment`, and reach +its initial pane and future panes. Pass credential references instead; see +{ref}`safety` for details. `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. @@ -32,7 +33,7 @@ When you enable it, tmux environment arguments are added, but the spawned proces } ``` -Response: +Response ({class}`~libtmux_mcp.models.SessionInfo`): ```json { diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md index 584fdda0..2521d5b0 100644 --- a/docs/tools/session/create-window.md +++ b/docs/tools/session/create-window.md @@ -7,6 +7,12 @@ **Side effects:** Creates a new window. Attaches to it if `attach` is true. +`environment` accepts a mapping or JSON object string and applies only to the +process started in the new window; it does not change the tmux session +environment. Values can remain visible in the tmux client argv and child +environment even though the MCP audit record is redacted. Pass credential +references, not literal credentials; see {ref}`safety`. + `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. Set it to `true` and {tooliconl}`create-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so future windows and panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. @@ -25,7 +31,23 @@ When you enable it, tmux environment arguments are added, but the spawned proces } ``` -Response: +**Example — launch a test window with scoped settings:** + +```json +{ + "tool": "create_window", + "arguments": { + "session_name": "dev", + "window_name": "tests", + "environment": { + "PYTHONPATH": "src" + }, + "suppress_persistent_history": true + } +} +``` + +Response ({class}`~libtmux_mcp.models.WindowInfo`): ```json { diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md index fca105b4..18e6514c 100644 --- a/docs/tools/window/split-window.md +++ b/docs/tools/window/split-window.md @@ -8,6 +8,12 @@ window. **Side effects:** Creates a new pane by splitting an existing one. +`environment` accepts a mapping or JSON object string and applies only to the +process started in the new pane; it does not change the tmux session +environment. Values can remain visible in the tmux client argv and child +environment even though the MCP audit record is redacted. Pass credential +references, not literal credentials; see {ref}`safety`. + `suppress_persistent_history` defaults to `false` for MCP and direct Python calls. It does not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`. Leave it `false` to add no history controls for this call. That choice cannot remove inherited, session, or startup-file controls. Set it to `true` and {tooliconl}`split-window` copies and merges best-effort no-disk history controls for only the spawned process. It does not change the tmux session environment, so later panes do not receive the controls from this call. The shell can retain in-memory history, and a startup file can override these controls after the process starts. @@ -26,7 +32,23 @@ When you enable it, tmux environment arguments are added, but the spawned proces } ``` -Response: +**Example — split off a test pane with scoped settings:** + +```json +{ + "tool": "split_window", + "arguments": { + "session_name": "dev", + "direction": "right", + "environment": { + "APP_ENV": "test" + }, + "suppress_persistent_history": true + } +} +``` + +Response ({class}`~libtmux_mcp.models.PaneInfo`): ```json { diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index e60f03e1..9bc678e4 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -74,29 +74,16 @@ Pane IDs like `%0`, `%5`, `%12` are unique across all sessions and windows withi However, they reset when the tmux **server** restarts. Do not cache pane IDs across server restarts. After killing and recreating a session, re-discover pane IDs with {tooliconl}`list-panes`. -(history-hygiene)= - ## Shell-history suppression is best effort -Most authored commands need no extra option: `suppress_history` is enabled by default for omitted MCP calls to {tooliconl}`run-command`. The tool prepends one ASCII space to the grouped event that carries your single-line command. When suppression is effective, a command containing a carriage return or line feed is rejected before tmux receives input; set `suppress_history=false` for intentional multiline input. {envvar}`LIBTMUX_SUPPRESS_HISTORY` controls this omitted MCP default, while an explicit argument wins. Direct Python calls still default to `False`. - -The prefix reduces one history-persistence path; it does not make terminal input private. For [Bash 5.3](https://github.com/tianon/mirror-bash/blob/bash-5.3/doc/bashref.texi#L7274-L7282), the event is skipped only when `HISTCONTROL` contains `ignorespace` or `ignoreboth`. `ignorespace` is not a Bash default, although system or user configuration may enable it. - -[Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) requires `HIST_IGNORE_SPACE` for the same prefix convention, and an ignored event can linger in internal history until the next event. [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) normally keeps a leading-space command off disk but leaves it recallable until the next command. A custom [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) function takes over that decision and can store leading-space commands, while [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) can strip leading spaces from pasted text. Do not treat the prefix convention as protection for paste input. - -For a process you are about to spawn, use the separate `suppress_persistent_history=true` option only when you want stronger best-effort no-disk controls. It defaults to `false` for both MCP and direct Python calls and never inherits {envvar}`LIBTMUX_SUPPRESS_HISTORY`. {toolref}`create-session` stores the controls in the new tmux session environment, so they reach the initial pane and future panes in that session. {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` apply them to only the process that each call starts; they do not change the tmux session environment. - -When enabled, the spawn tools copy and merge an empty `HISTFILE`, Bash's `HISTCONTROL`, and Fish private-history variables into the new environment. The result remains best effort: - -| Shell | Spawn controls | Remaining caveat | -|-------|----------------|------------------| -| Bash | Empty `HISTFILE`; add `ignorespace` unless `ignoreboth` is already present | The interactive process can retain in-memory history. | -| Zsh | Empty `HISTFILE` | The interactive process can retain in-memory history. | -| Fish | Empty `fish_history`; non-empty `fish_private_mode` | The interactive process can retain in-memory history. | - -Startup files can override any of these environment values after the process begins, so the shell can resume writing history to disk. Leaving `suppress_persistent_history=false` adds no controls, but it cannot remove inherited, session, or startup-file controls. The process can still receive history settings from the tmux server or session environment, your supplied `environment`, or its startup files. +MCP calls to {tooliconl}`run-command` request lightweight suppression by +default, but Bash, Zsh, and Fish each decide whether and how a command reaches +history. Stronger no-disk controls for newly spawned shells are available only +when you opt into `suppress_persistent_history=true`. -Raw and TUI input stays outside both defaults. {tooliconl}`send-keys` and {tooliconl}`send-keys-batch` do not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`; their `suppress_history` values remain explicit and default to `false`. Enabling it adds one leading space, so do not use it for control keys such as `C-c`, TUI input, or partial text unless that byte is intentional. In a batch, choose suppression separately for each operation. Paste tools have no suppression argument: {toolref}`paste-text` and {toolref}`paste-buffer` add no history prefix, but the active program can still interpret the paste or its whitespace. See {ref}`safety` for pane output, capture, process, transcript, hook, and log surfaces that remain visible. +Neither control makes commands secret, and raw TUI or paste input stays +outside the default. See {ref}`history-suppression` for the two-level model, +shell-specific behavior, multiline commands, scope, and remaining visibility. ## Gemini CLI injects `wait_for_previous` into tool arguments diff --git a/docs/topics/history-suppression.md b/docs/topics/history-suppression.md new file mode 100644 index 00000000..e6a014bf --- /dev/null +++ b/docs/topics/history-suppression.md @@ -0,0 +1,143 @@ +(history-suppression)= +(history-hygiene)= + +# History suppression + +libtmux-mcp provides best-effort shell-history suppression for Bash, Zsh, +and Fish. For most single-line commands authored through MCP, you do not need +to change anything: `suppress_history` is enabled by default for omitted MCP +calls to {tooliconl}`run-command`. When you start a new shell and want stronger +best-effort no-disk controls, opt in with `suppress_persistent_history=true`. + +Neither control makes a command secret. Shell configuration can override the +request, in-memory history can remain available, and terminal output or other +observers can still record the command. See {ref}`safety` before handling +credentials. + +## Why raw input stays explicit + +Using {tooliconl}`send-keys` for every command an agent authors can fill an +interactive shell's history with orchestration noise. libtmux-mcp deliberately +stays out of the way on raw input: {tooliconl}`send-keys` and +{tooliconl}`send-keys-batch` preserve the caller's keystrokes, do not inherit +the server history default, and keep their own `suppress_history` arguments +off unless the caller opts in. + +That literal behavior is necessary for control keys, partial text, REPLs, and +TUIs. For an authored single-line shell command, prefer +{tooliconl}`run-command`; it provides completion and output as one typed result +and requests lightweight history suppression by default. + +## Choose the control + +### One authored command + +Use `suppress_history` on {tooliconl}`run-command`. It is enabled when omitted +by an MCP caller and applies to one command event in the existing shell. + +### A newly spawned shell + +Use `suppress_persistent_history=true` on {tooliconl}`create-session`, +{tooliconl}`create-window`, {tooliconl}`split-window`, or +{tooliconl}`respawn-pane`. This stronger no-disk control is disabled by +default, so you opt in per call. It applies to the new session environment or +to the one process being spawned, depending on the tool. + +The two controls are independent. {envvar}`LIBTMUX_SUPPRESS_HISTORY` changes +only the omitted MCP default for {toolref}`run-command`; it never enables the +spawn control. Direct Python calls default both arguments to `False`. + +## Default suppression for authored commands + +{tooliconl}`run-command` prepends one ASCII space to the grouped event that +carries your single-line command. {envvar}`LIBTMUX_SUPPRESS_HISTORY` defaults +to `1`, so MCP calls that omit `suppress_history` request this lightweight +suppression. An explicit argument always wins. + +One prefix cannot protect several shell events. When suppression is enabled, +a command containing a carriage return or line feed fails before tmux receives +input. Pass `suppress_history=false` when multiline input is intentional. The +{ref}`configuration` page covers startup values, validation, and restart +behavior. + +## Bash, Zsh, and Fish behavior + +The leading-space convention depends on the shell already running in the +pane: + +- [Bash 5.3](https://github.com/tianon/mirror-bash/blob/bash-5.3/doc/bashref.texi#L7274-L7282) + skips the event only when `HISTCONTROL` contains `ignorespace` or + `ignoreboth`. `ignorespace` is not a Bash default, although system or user + configuration may enable it. +- [Zsh 5.9](https://github.com/zsh-users/zsh/blob/zsh-5.9/Doc/Zsh/options.yo) + requires `HIST_IGNORE_SPACE`. An ignored event can remain in internal + history until the next event. +- [Fish 4.8](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/interactive.rst) + normally keeps a leading-space command off disk but leaves it recallable + until the next command. A custom + [`fish_should_add_to_history`](https://github.com/fish-shell/fish-shell/blob/4.8.0/doc_src/cmds/fish_should_add_to_history.rst) + function can store it, and + [bracketed paste handling](https://github.com/fish-shell/fish-shell/blob/4.8.0/CHANGELOG.rst) + can strip leading spaces from pasted text. + +These are shell conventions, not an isolation boundary. Startup files and +interactive configuration remain authoritative. + +## Opt into stronger controls for a new shell + +Set `suppress_persistent_history=true` when you are about to spawn a Bash, +Zsh, or Fish shell and want stronger best-effort no-disk controls. The spawn +tools copy and merge history settings into the new environment: + +### Bash + +The spawned environment uses an empty `HISTFILE` and adds `ignorespace` to +`HISTCONTROL` unless `ignoreboth` is already present. The interactive process +can still retain in-memory history. + +### Zsh + +The spawned environment uses an empty `HISTFILE`. The interactive process can +still retain in-memory history. + +### Fish + +The spawned environment uses an empty `fish_history` and a non-empty +`fish_private_mode`. The interactive process can still retain in-memory +history. + +Scope follows the tmux object you create: + +- {tooliconl}`create-session` stores the controls in the new session + environment, so the initial pane and future panes inherit them. +- {tooliconl}`create-window`, {tooliconl}`split-window`, and + {tooliconl}`respawn-pane` apply the controls only to the process started by + that call. They do not change the tmux session environment. + +If you also supply `environment`, any history-control values must agree with +the requested policy. A conflict fails the call, names the variable without +including its value, and is never retried without suppression. Startup files +can still replace the merged values after the process starts. Leaving the +option `false` adds no controls and cannot remove settings inherited from +tmux, the caller, or a startup file. + +## Raw input and paste stay explicit + +{tooliconl}`send-keys` and {tooliconl}`send-keys-batch` do not inherit +{envvar}`LIBTMUX_SUPPRESS_HISTORY`; their `suppress_history` arguments remain +explicit and default to `false`. A leading space is usually wrong for control +keys such as `C-c`, TUI input, or partial text. In a batch, choose suppression +separately for each operation. + +Paste tools have no suppression argument. {toolref}`paste-text` and +{toolref}`paste-buffer` add no history prefix, and the active program can +interpret the paste or its whitespace. + +## What remains visible + +History suppression does not clear pane echo, scrollback, in-memory history, +process arguments, tmux environment state, MCP client transcripts, hooks, or +logs. Prefer credential references that the child process resolves over +literal credentials in `command`, `keys`, `text`, `shell`, or `environment`. +See {ref}`safety` for the full observation boundary and {ref}`logging` for +audit-record behavior. diff --git a/docs/topics/index.md b/docs/topics/index.md index 7c03282e..cb006286 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -23,6 +23,12 @@ tmux hierarchy, MCP protocol, and the mental model. Three-tier safety system for controlling tool access. ::: +:::{grid-item-card} History Suppression +:link: history-suppression +:link-type: doc +Best-effort Bash, Zsh, and Fish history controls and their limits. +::: + :::{grid-item-card} Troubleshooting :link: troubleshooting :link-type: doc @@ -81,6 +87,7 @@ observation cursors. architecture concepts safety +history-suppression gotchas prompting completion diff --git a/docs/topics/safety.md b/docs/topics/safety.md index f5c94f44..32084c28 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -125,7 +125,7 @@ These can execute anything the pane's shell accepts. There is no payload validat - **pane echo and scrollback:** the terminal can display input, tmux can retain it in pane history, and an attached terminal can keep its own scrollback. - **capture tools and piping:** {toolref}`capture-pane`, {toolref}`capture-since`, {toolref}`snapshot-pane`, {toolref}`search-panes`, and {toolref}`pipe-pane` can return or route displayed and retained text. - **hooks:** configured tmux hooks, including state visible through {tooliconl}`show-hooks`, and shell instrumentation can observe process or pane activity independently of shell history. -- **process visibility:** command arguments and launch strings can appear in the tmux client argv. Environment values passed to {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` can also remain in a child process environment; {toolref}`create-session` retains them in tmux session state for future panes. MCP audit redaction does not hide any of these surfaces from host process or tmux environment inspection. +- **process visibility:** command arguments and launch strings can appear in the tmux client argv. Environment values passed to {toolref}`create-session`, {toolref}`create-window`, {toolref}`split-window`, and {toolref}`respawn-pane` can also remain in a child process environment; {toolref}`create-session` retains them in tmux session state for future panes, where {toolref}`show-environment` can reveal them. MCP audit redaction does not hide any of these surfaces from host process or tmux environment inspection. - **MCP client transcripts:** clients can retain the original request and response outside the server's control. - **logs:** `libtmux_mcp.audit` records redacted arguments and whether the call succeeded or raised; it does not contain tool return values. Redaction applies only to these audit records and does not rewrite separate records emitted by libtmux, FastMCP, shells, or MCP clients. libtmux DEBUG or error records may contain shell-joined tmux arguments, while MCP client request logs and application logs remain outside the server's guarantee. diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index a0eb9708..a5ff9c40 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -100,46 +100,49 @@ def test_configuration_separates_spawn_persistent_history_control( assert f"{{toolref}}`{tool}`" in text -def test_history_gotcha_documents_shell_limits_and_raw_input_boundary( +def test_history_topic_documents_shell_limits_and_raw_input_boundary( docs_dir: pathlib.Path, ) -> None: """History guidance stays best effort and corrects the Bash default claim.""" - text = (docs_dir / "topics" / "gotchas.md").read_text(encoding="utf-8") + text = (docs_dir / "topics" / "history-suppression.md").read_text(encoding="utf-8") + normalized = " ".join(text.split()) - assert "## Shell-history suppression is best effort" in text - assert "`ignorespace` is not a Bash default" in text + assert "# History suppression" in text + assert "`ignorespace` is not a Bash default" in normalized assert "(history-hygiene)=" in text - assert "Startup files can override" in text - assert "in-memory history" in text + assert "(history-suppression)=" in text + assert "Startup files can still replace" in normalized + assert "in-memory history" in normalized assert "HIST_IGNORE_SPACE" in text assert "fish_should_add_to_history" in text - assert "bracketed paste" in text - assert "recallable until the next command" in text - assert "enabled by default for omitted MCP calls" in text + assert "bracketed paste" in normalized + assert "recallable until the next command" in normalized + assert "enabled by default for omitted MCP calls" in normalized assert "`suppress_persistent_history=true`" in text - assert "defaults to `false`" in text - assert "initial pane and future panes in that session" in text - assert "only the process that each call starts" in text - assert "cannot remove inherited, session, or startup-file controls" in text + assert "disabled by default, so you opt in per call" in normalized + assert "initial pane and future panes inherit" in normalized + assert "only to the process started by" in normalized + assert "cannot remove settings inherited" in normalized + assert "fill an interactive shell's history with orchestration noise" in normalized + assert "deliberately stays out of the way on raw input" in normalized assert "{tooliconl}`send-keys-batch`" in text - assert "do not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`" in text - assert "control keys such as `C-c`, TUI input, or partial text" in text - assert "Paste tools have no suppression argument" in text + assert "do not inherit {envvar}`LIBTMUX_SUPPRESS_HISTORY`" in normalized + assert "control keys such as `C-c`, TUI input, or partial text" in normalized + assert "Paste tools have no suppression argument" in normalized assert "github.com/tianon/mirror-bash/blob/bash-5.3" in text assert "the default for bash" not in text - spawn_scope = next( - paragraph - for paragraph in text.split("\n\n") - if paragraph.startswith("For a process you are about to spawn") - ) + assert "| Workflow |" not in text + assert "| Shell |" not in text + spawn_scope = text.split("## Opt into stronger controls for a new shell", 1)[ + 1 + ].split("## Raw input and paste stay explicit", 1)[0] for tool in ( "create-session", "create-window", "split-window", "respawn-pane", ): - assert f"{{toolref}}`{tool}`" in spawn_scope - assert f"{{tooliconl}}`{tool}`" not in spawn_scope + assert f"{{tooliconl}}`{tool}`" in spawn_scope @pytest.mark.parametrize( From 0c83e185be4ba9fbf1ca03f16736f0bf40ce6a4d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 10 Jul 2026 20:12:52 -0500 Subject: [PATCH 20/20] mcp(docs[history]): Focus entry on features why: The unreleased changelog should describe the branch in user-facing terms without release ceremony or implementation detail. what: - Separate compatibility, spawned-shell, and environment capabilities - Link migration and safety detail to focused topic guidance --- CHANGES | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGES b/CHANGES index 1b365eef..d128cbfd 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,24 @@ _Notes on upcoming releases will be added here_ +### Breaking changes + +**History suppression now defaults on for run commands** + +MCP clients now see the effective server default for `suppress_history` on {tooliconl}`run-command`, and calls that omit the argument inherit it. The default requests best-effort history suppression. To run multiline input while suppression is enabled, pass `suppress_history=false`; to retain the previous omitted-call behavior, set {envvar}`LIBTMUX_SUPPRESS_HISTORY` to `0` and restart the server. Direct Python calls remain unchanged. See {ref}`configuration` for details. (#91) + +### What's new + +**History controls for spawned shells** + +{tooliconl}`create-session`, {tooliconl}`create-window`, {tooliconl}`split-window`, and {tooliconl}`respawn-pane` now let callers opt into best-effort no-disk shell-history controls with `suppress_persistent_history=true`. Session controls reach the initial and future panes, while the other tools limit the setting to the process launched by that call. + +History suppression reduces accidental persistence, but it is not secret transport: shells can override the controls, and terminal output and other observation surfaces remain visible. See {ref}`history-hygiene` for shell behavior and {ref}`safety` for the remaining boundaries. (#91) + +**Per-process environments for windows and panes** + +{tooliconl}`create-window` and {tooliconl}`split-window` now accept per-process `environment` mappings or JSON object strings, so callers can configure new windows and panes without changing the tmux session environment. {tooliconl}`respawn-pane` now accepts the same JSON object form for its existing environment input. Values can still surface in host process inspection and child environments; use credential references, not literal credentials. See {ref}`safety` for details. (#91) + ### Documentation **Grok CLI and Antigravity in the MCP install picker**