Skip to content

Commit 58a0ce2

Browse files
committed
mcp(feat[pane_tools]): expose respawn-pane environment override
Closes the parity gap with libtmux's ``Pane.respawn(environment=)`` on the ``tmux-parity`` branch. The new ``environment: dict[str, str]`` parameter on ``respawn_pane`` maps to one ``-e KEY=VALUE`` flag per entry (single-arg ``-e<KEY>=<VAL>`` form, mirroring upstream's emitter — tmux's ``cmd-respawn-pane.c`` accepts both joined and split forms but upstream uses joined). The stopgap comment is updated to include ``-e`` so the eventual swap to ``pane.respawn(environment=)`` is a single internal change. Audit-log redaction is extended to recognise dict-shaped sensitive args. Each ``environment`` *value* is replaced by a ``{len, sha256_prefix}`` digest while keys remain visible (env var names like ``DATABASE_URL`` are operator-debug-useful; values are the secret). The same OS-process-table caveat as ``shell`` applies and is documented in ``docs/topics/safety.md`` under the ``respawn_pane`` subsection — the audit log redacts, but ``ps`` may still observe the flag string briefly before the spawned process inherits the env. Tests cover the new redaction shape (`tests/test_middleware.py`) and the runtime propagation path (`tests/test_pane_tools.py` — ``printenv`` under ``remain-on-exit`` so the assertion runs against captured pane content, with ``capture-pane -S -50`` to read enough scrollback even on a small pane).
1 parent 6432646 commit 58a0ce2

7 files changed

Lines changed: 168 additions & 16 deletions

File tree

CHANGES

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66
_Notes on upcoming releases will be added here_
77
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
88

9+
### Features
10+
11+
- {tooliconl}`respawn-pane` gains an ``environment`` parameter
12+
(``dict[str, str]``) that maps to tmux's ``respawn-pane -e
13+
KEY=VALUE`` flag (one ``-e`` per entry, single-arg ``-e<KEY>=<VAL>``
14+
form to mirror the upstream emitter). Closes the parity gap with
15+
``Pane.respawn(environment=)`` on libtmux's ``tmux-parity`` branch.
16+
The audit-log redaction policy is extended to recognise dict-shaped
17+
sensitive args: each value is replaced by a ``{len, sha256_prefix}``
18+
digest while keys (env var names like ``DATABASE_URL``) remain
19+
visible — keys are operator-debug-useful, values are the secret.
20+
Note: like ``shell``, env var values may briefly appear in the OS
21+
process table before the spawned shell inherits them; do not pass
22+
long-lived secrets when other tenants on the host could observe
23+
``ps``.
24+
925
### Tests
1026

1127
- New ``test_registered_tools_accept_socket_name`` introspection test

docs/tools/pane/respawn-pane.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ process, bad terminal mode) and you need a clean restart *without*
88
destroying the `pane_id` references other tools or callers may still
99
be holding. With `kill=True` (the default) tmux kills the current
1010
process first; optional `shell` relaunches with a different command;
11-
optional `start_directory` sets its cwd.
11+
optional `start_directory` sets its cwd; optional `environment` adds
12+
per-process env vars (one `-e KEY=VALUE` flag per entry).
1213

1314
**Avoid when** the pane genuinely needs to go away — use
1415
{tooliconl}`kill-pane` instead. Also avoid when you want to change
@@ -48,6 +49,24 @@ time).
4849
}
4950
```
5051

52+
**Example — relaunch with extra environment variables:**
53+
54+
```json
55+
{
56+
"tool": "respawn_pane",
57+
"arguments": {
58+
"pane_id": "%5",
59+
"shell": "pytest -x",
60+
"environment": {
61+
"PYTHONPATH": "/home/user/project/src",
62+
"DATABASE_URL": "postgres://localhost/test"
63+
}
64+
}
65+
}
66+
```
67+
68+
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.
69+
5170
Response (PaneInfo):
5271

5372
```json

docs/topics/safety.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Mitigations:
104104

105105
- `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get a `ToolError` instead of an unintended kill — resolve via {tool}`list-panes` first.
106106
- 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.
107+
- 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.
107108
- The same self-pane guard that protects the destructive kill commands also refuses to respawn the pane running the MCP server.
108109

109110
### `send_keys` / `paste_text`
@@ -118,7 +119,7 @@ Every tool call emits one `INFO` record on the `libtmux_mcp.audit` logger carryi
118119
- `outcome``ok` or `error`, with `error_type` on failure
119120
- `duration_ms`
120121
- `client_id` / `request_id` — from the fastmcp context when available
121-
- `args` — a summary of arguments. Sensitive keys (`keys`, `text`, `value`, `content`, `shell`) are replaced by `{len, sha256_prefix}`; non-sensitive strings over 200 characters are truncated.
122+
- `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.
122123

123124
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.
124125

src/libtmux_mcp/middleware.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,23 @@ async def on_call_tool(
100100

101101
#: Argument names that carry user-supplied payloads we never want in logs.
102102
#: ``keys`` (send_keys), ``text`` (paste_text), ``value`` (set_environment),
103-
#: ``content`` (load_buffer), and ``shell`` (respawn_pane) can contain
104-
#: commands, secrets, or arbitrary large strings. Matched by exact name,
105-
#: case-sensitive, to mirror the tool signatures.
103+
#: ``content`` (load_buffer), ``shell`` (respawn_pane), and ``environment``
104+
#: (respawn_pane) can contain commands, secrets, or arbitrary large strings.
105+
#: Matched by exact name, case-sensitive, to mirror the tool signatures.
106106
#:
107-
#: Note on ``shell`` redaction: this redacts the MCP audit log only.
108-
#: ``respawn_pane(shell="env SECRET=... bash")`` may briefly expose the
109-
#: argument via the OS process table and tmux's ``pane_current_command``
110-
#: metadata until the spawned shell takes over — see ``docs/topics/safety.md``.
107+
#: ``environment`` is dict-shaped (``dict[str, str]``); the redaction logic
108+
#: in :func:`_summarize_args` recognises this and digests each *value* while
109+
#: leaving the *keys* (env var names like ``DATABASE_URL``) visible — env
110+
#: var names are operator-debug-useful, but their values are the secret.
111+
#: All other entries are scalar strings; mixing the two is intentional.
112+
#:
113+
#: Note on ``shell`` and ``environment`` redaction: this redacts the MCP
114+
#: audit log only. ``respawn_pane(shell="env SECRET=... bash")`` and
115+
#: ``environment={"AWS_SECRET_KEY": "..."}`` may briefly expose the values
116+
#: via the OS process table and tmux's ``pane_current_command`` metadata
117+
#: until the spawned shell takes over — see ``docs/topics/safety.md``.
111118
_SENSITIVE_ARG_NAMES: frozenset[str] = frozenset(
112-
{"keys", "text", "value", "content", "shell"}
119+
{"keys", "text", "value", "content", "shell", "environment"}
113120
)
114121

115122
#: String arguments longer than this get truncated in the log summary to
@@ -143,6 +150,10 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]:
143150
144151
Sensitive keys get replaced by a digest; over-long strings get
145152
truncated with a marker; everything else passes through as-is.
153+
Sensitive values that are dict-shaped (e.g. ``environment`` on
154+
``respawn_pane``) have each *value* digested while keys remain
155+
visible — env-var-name-like keys are operator-debug-useful and
156+
rarely sensitive, while their values usually are.
146157
147158
Examples
148159
--------
@@ -155,11 +166,21 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]:
155166
156167
>>> _summarize_args({"keys": "rm -rf /"})["keys"]["len"]
157168
8
169+
170+
Sensitive dict-shaped payloads keep their keys but digest values:
171+
172+
>>> redacted = _summarize_args({"environment": {"FOO": "bar"}})
173+
>>> redacted["environment"]["FOO"]["len"]
174+
3
175+
>>> "bar" in str(redacted)
176+
False
158177
"""
159178
summary: dict[str, t.Any] = {}
160179
for key, value in args.items():
161-
if isinstance(value, str) and key in _SENSITIVE_ARG_NAMES:
180+
if key in _SENSITIVE_ARG_NAMES and isinstance(value, str):
162181
summary[key] = _redact_digest(value)
182+
elif key in _SENSITIVE_ARG_NAMES and isinstance(value, dict):
183+
summary[key] = {k: _redact_digest(str(v)) for k, v in value.items()}
163184
elif isinstance(value, str) and len(value) > _MAX_LOGGED_STR_LEN:
164185
summary[key] = value[:_MAX_LOGGED_STR_LEN] + "...<truncated>"
165186
else:

src/libtmux_mcp/tools/pane_tools/lifecycle.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def respawn_pane(
6767
kill: bool = True,
6868
shell: str | None = None,
6969
start_directory: str | None = None,
70+
environment: dict[str, str] | None = None,
7071
socket_name: str | None = None,
7172
) -> PaneInfo:
7273
"""Restart a pane's process in place, preserving pane_id and layout.
@@ -79,7 +80,9 @@ def respawn_pane(
7980
With ``kill=True`` (the default), tmux kills the existing process
8081
before respawning. Optional ``shell`` replaces the command tmux
8182
relaunches; ``start_directory`` sets the working directory for
82-
the new process.
83+
the new process; ``environment`` sets per-process environment
84+
variables for the relaunched command (one ``-e KEY=VALUE`` flag
85+
per entry).
8386
8487
``pane_id`` is required — no fallback to ``_resolve_pane``'s
8588
"first pane in session/window" behaviour. Default ``kill=True``
@@ -118,6 +121,16 @@ def respawn_pane(
118121
start_directory : str, optional
119122
Working directory for the relaunched command (maps to
120123
``respawn-pane -c``).
124+
environment : dict[str, str], optional
125+
Environment variables to set for the relaunched process. Each
126+
item becomes one ``-e KEY=VALUE`` flag (tmux's
127+
``cmd-respawn-pane.c`` supports the flag repeatedly). Values
128+
are redacted in the audit log on a per-key basis — keys like
129+
``DATABASE_URL`` remain visible but their values are replaced
130+
by ``{len, sha256_prefix}`` digests. Note that the values may
131+
still appear briefly in the OS process table while tmux spawns
132+
the new process; do not pass long-lived secrets here when a
133+
host-resident agent or other tenant could observe ``ps``.
121134
socket_name : str, optional
122135
tmux socket name.
123136
@@ -155,16 +168,21 @@ def respawn_pane(
155168
raise ToolError(msg)
156169
# Stopgap: ``libtmux>=0.55.1`` has no ``Pane.respawn()`` yet — the
157170
# wrapper exists on the upstream ``tmux-parity`` branch (see
158-
# ``libtmux/pane.py:respawn``) and mirrors this argv shape (``-k``,
159-
# ``-c <dir>``, optional trailing shell). When the release line picks
160-
# it up, swap ``pane.cmd("respawn-pane", *argv)`` for ``pane.respawn(
161-
# kill=kill, start_directory=start_directory, shell=shell)`` and drop
171+
# ``libtmux/pane.py:respawn``) and mirrors this argv shape: ``-k``,
172+
# ``-c <dir>``, repeated ``-e<KEY>=<VAL>`` (single-arg form, NOT
173+
# split ``-e KEY=VAL`` — tmux's args parser accepts both but
174+
# upstream emits the joined form), then optional trailing shell.
175+
# When the release line picks it up, swap ``pane.cmd("respawn-pane",
176+
# *argv)`` for ``pane.respawn(kill=kill, start_directory=
177+
# start_directory, environment=environment, shell=shell)`` and drop
162178
# the stderr branch — ``Pane.respawn`` raises ``LibTmuxException``.
163179
argv: list[str] = []
164180
if kill:
165181
argv.append("-k")
166182
if start_directory is not None:
167183
argv.extend(["-c", start_directory])
184+
if environment:
185+
argv.extend(f"-e{k}={v}" for k, v in environment.items())
168186
if shell is not None:
169187
argv.append(shell)
170188
result = pane.cmd("respawn-pane", *argv)

tests/test_middleware.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,38 @@ def test_summarize_args_redacts_sensitive_keys() -> None:
167167
assert summary["bracket"] is True
168168

169169

170+
def test_summarize_args_redacts_sensitive_dict_values() -> None:
171+
"""Dict-shaped sensitive args keep keys but digest values per-entry.
172+
173+
``environment`` on ``respawn_pane`` is a ``dict[str, str]``. The
174+
values typically carry secrets (DB passwords, API keys), but the
175+
keys (``DATABASE_URL``, ``AWS_SECRET_KEY``) are operator-useful for
176+
debugging which env var was set. The redaction policy preserves
177+
keys and digests values.
178+
"""
179+
args: dict[str, t.Any] = {
180+
"environment": {
181+
"DATABASE_URL": "postgres://user:hunter2@db/app",
182+
"AWS_SECRET_KEY": "AKIAIOSFODNN7EXAMPLE",
183+
},
184+
"pane_id": "%1",
185+
}
186+
summary = _summarize_args(args)
187+
assert isinstance(summary["environment"], dict)
188+
assert set(summary["environment"].keys()) == {"DATABASE_URL", "AWS_SECRET_KEY"}
189+
for key in ("DATABASE_URL", "AWS_SECRET_KEY"):
190+
digest = summary["environment"][key]
191+
assert isinstance(digest, dict)
192+
assert "len" in digest
193+
assert "sha256_prefix" in digest
194+
# No value bytes leak into the rendered summary.
195+
rendered = str(summary)
196+
assert "hunter2" not in rendered
197+
assert "AKIAIOSFODNN7EXAMPLE" not in rendered
198+
# Non-sensitive args still pass through.
199+
assert summary["pane_id"] == "%1"
200+
201+
170202
def test_summarize_args_truncates_long_non_sensitive_strings() -> None:
171203
"""Non-sensitive strings over the cap get truncated with a marker."""
172204
args = {"output_path": "x" * 500}

tests/test_pane_tools.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,51 @@ def test_respawn_pane_kill_false_on_live_pane_raises(
405405
new_pane.kill()
406406

407407

408+
def test_respawn_pane_with_environment(
409+
mcp_server: Server, mcp_session: Session
410+
) -> None:
411+
"""``environment`` propagates through to the relaunched process.
412+
413+
tmux's ``respawn-pane -e KEY=VALUE`` sets per-process env vars on
414+
the spawned command (``cmd-respawn-pane.c`` accepts the flag
415+
repeatedly). Verify by relaunching with ``sh -c 'env'`` under
416+
``remain-on-exit`` so we can capture the env output after the
417+
process exits without tmux deleting the pane out from under us.
418+
"""
419+
window = mcp_session.active_window
420+
window.cmd("set-option", "-w", "remain-on-exit", "on")
421+
new_pane = window.split(shell="sleep 3600")
422+
assert new_pane.pane_id is not None
423+
424+
# Use ``printenv`` over ``env`` so the output fits the visible pane
425+
# (default capture-pane reads only the visible screen, not history).
426+
# Wrap the values in markers so we don't false-match on similarly
427+
# named host env vars that might already be set.
428+
result = respawn_pane(
429+
pane_id=new_pane.pane_id,
430+
shell="sh -c 'printenv LIBTMUX_TEST_FOO LIBTMUX_TEST_BAZ'",
431+
environment={"LIBTMUX_TEST_FOO": "bar", "LIBTMUX_TEST_BAZ": "qux"},
432+
socket_name=mcp_server.socket_name,
433+
)
434+
assert result.pane_id == new_pane.pane_id
435+
436+
def _pane_dead() -> bool:
437+
out = new_pane.cmd("display-message", "-p", "#{pane_dead}").stdout
438+
return bool(out) and out[0].strip() == "1"
439+
440+
retry_until(_pane_dead, seconds=5, raises=True)
441+
442+
# ``-S -50`` reads the last 50 lines of scrollback so we don't lose
443+
# the first ``printenv`` line off the top of the visible screen.
444+
captured = new_pane.cmd("capture-pane", "-p", "-S", "-50").stdout
445+
rendered = "\n".join(captured)
446+
assert "bar" in rendered
447+
assert "qux" in rendered
448+
449+
new_pane.kill()
450+
window.cmd("set-option", "-wu", "remain-on-exit")
451+
452+
408453
# ---------------------------------------------------------------------------
409454
# search_panes tests
410455
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)