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** diff --git a/docs/configuration.md b/docs/configuration.md index f94108ca..2b2aa329 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,25 @@ 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 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:** `1` (enabled) +- **Values:** `0`, `1` + +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`. + +{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. {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`. + +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 Set environment variables in your MCP client config: @@ -51,7 +70,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/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 0a063ce2..7142910f 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -19,6 +19,12 @@ 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. +`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}`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. + +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. Omitting `shell` makes tmux replay the original argv (good default for @@ -65,9 +71,9 @@ 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): +Response ({class}`~libtmux_mcp.models.PaneInfo`): ```json5 { diff --git a/docs/tools/pane/run-command.md b/docs/tools/pane/run-command.md index 7cb4ffe2..c2e669d9 100644 --- a/docs/tools/pane/run-command.md +++ b/docs/tools/pane/run-command.md @@ -3,19 +3,19 @@ ```{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 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, 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:** @@ -30,7 +30,7 @@ honor leading-space history suppression. } ``` -Response: +Response ({class}`~libtmux_mcp.models.RunCommandResult`): ```json { @@ -44,5 +44,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 3288794a..bb65860b 100644 --- a/docs/tools/server/create-session.md +++ b/docs/tools/server/create-session.md @@ -11,6 +11,17 @@ 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, 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. + +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. + +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:** ```json @@ -22,7 +33,7 @@ container — create one before creating windows or panes. } ``` -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 b3954532..2521d5b0 100644 --- a/docs/tools/session/create-window.md +++ b/docs/tools/session/create-window.md @@ -7,6 +7,18 @@ **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. + +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:** ```json @@ -19,7 +31,23 @@ } ``` -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 a457324c..18e6514c 100644 --- a/docs/tools/window/split-window.md +++ b/docs/tools/window/split-window.md @@ -8,6 +8,18 @@ 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. + +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:** ```json @@ -20,7 +32,23 @@ window. } ``` -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 4ac88f4a..9bc678e4 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -74,13 +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`. -## `suppress_history` requires shell support +## Shell-history suppression is best effort -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). +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`. + +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/logging.md b/docs/topics/logging.md index 59094131..a4bcd01f 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 @@ -62,11 +59,12 @@ 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 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..32084c28 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 @@ -111,12 +111,25 @@ 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 -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 + +`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:** {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, 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. + +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 @@ -126,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/src/libtmux_mcp/_history.py b/src/libtmux_mcp/_history.py new file mode 100644 index 00000000..1c42bf0c --- /dev/null +++ b/src/libtmux_mcp/_history.py @@ -0,0 +1,122 @@ +"""Semantic shell-history policy helpers.""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +_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 == "1": + return True + if value == "0": + return False + 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, ...] = _COMMAND_HISTORY_DEFAULT_TOOLS, +) -> None: + """Publish the effective MCP default for semantic command tools. + + Parameters + ---------- + mcp : FastMCP + Server receiving the public tool transform. + enabled : bool + Effective startup default to publish. + tool_names : tuple[str, ...] + 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 + + argument = ArgTransformConfig(default=enabled) + mcp.add_transform( + ToolTransform( + { + name: ToolTransformConfig(arguments={"suppress_history": argument}) + for name in tool_names + } + ) + ) + + +def _prepare_spawn_environment( + environment: dict[str, str] | str | None, + *, + suppress_persistent_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_persistent_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_persistent_history: + return None if coerced is None else result + + required_values = { + "HISTFILE": "", + "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 " + f"suppress_persistent_history=True; {corrections[name]}" + ) + 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/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/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/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 849bb26b..f92a6404 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 = True, +) -> 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\nsuppress_history={history_default}: run_command inherits; " + "raw send/batch/paste and spawn do not." + ) # 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..54caecc2 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -362,8 +362,11 @@ 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. Suppression requires a single-line command; multiline + commands remain available when suppression is false. socket_name : str, optional tmux socket name. @@ -376,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/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index a522b260..56c702c4 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_persistent_history: bool = False, ) -> PaneInfo: """Restart a pane's process in place, preserving pane_id and layout. @@ -117,18 +120,25 @@ 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 - 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. + 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 ------- @@ -136,6 +146,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_persistent_history=suppress_persistent_history, + ) server = _get_server(socket_name=socket_name) pane = _resolve_pane(server, pane_id=pane_id) caller = _get_caller_identity() @@ -152,7 +166,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..d9ab55af 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_persistent_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,33 @@ 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 - :func:`libtmux_mcp._utils._coerce_dict_arg`. + 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`. 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 + 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 ------- SessionInfo The created session. """ + spawn_environment = _prepare_spawn_environment( + environment, + suppress_persistent_history=suppress_persistent_history, + ) server = _get_server(socket_name=socket_name) kwargs: dict[str, t.Any] = {} if session_name is not None: @@ -118,9 +136,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..7805c3ff 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_persistent_history: bool = False, ) -> WindowInfo: """Create a new window in a tmux session. @@ -138,12 +142,28 @@ 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. 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 + inherit LIBTMUX_SUPPRESS_HISTORY. Startup files may override these + controls. Returns ------- WindowInfo Serialized window object. """ + spawn_environment = _prepare_spawn_environment( + environment, + 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) kwargs: dict[str, t.Any] = {} @@ -163,6 +183,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..0a8461b3 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_persistent_history: bool = False, ) -> PaneInfo: """Split a tmux window to create a new pane. @@ -191,12 +195,28 @@ 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. 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 + inherit LIBTMUX_SUPPRESS_HISTORY. Startup files may override these + controls. Returns ------- PaneInfo Serialized pane object. """ + spawn_environment = _prepare_spawn_environment( + environment, + suppress_persistent_history=suppress_persistent_history, + ) server = _get_server(socket_name=socket_name) pane_dir: PaneDirection | None = None @@ -214,6 +234,7 @@ def split_window( size=size, start_directory=start_directory, shell=shell, + environment=spawn_environment, ) else: window = _resolve_window( @@ -228,6 +249,7 @@ def split_window( size=size, start_directory=start_directory, shell=shell, + environment=spawn_environment, ) return _serialize_pane(new_pane) diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 2a7cee6d..a5ff9c40 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -46,3 +46,351 @@ 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_command_history_default_and_restart( + docs_dir: pathlib.Path, +) -> None: + """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:** `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 "prefixes one space" 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 + 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"{{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 + + +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" / "history-suppression.md").read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert "# History suppression" in text + assert "`ignorespace` is not a Bash default" in normalized + assert "(history-hygiene)=" 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 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 "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 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 + 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"{{tooliconl}}`{tool}`" in spawn_scope + + +@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_persistent_history`" in text + assert required_scope 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 "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_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: + """The semantic command page distinguishes startup and explicit policy.""" + text = (docs_dir / "tools" / "pane" / "run-command.md").read_text(encoding="utf-8") + + 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 + 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( + 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 "`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 "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 + 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() + 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( + 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( + 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 + 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_summarizes_history_features( + docs_dir: pathlib.Path, +) -> None: + """The unreleased changelog stays focused on product-level features.""" + 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]) + + 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 "**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 diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 00000000..70929de9 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,1002 @@ +"""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, True), + 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), +] + +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." +) + + +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) + + +def test_history_transform_changes_exact_semantic_tool_set() -> None: + """Only run-command inherits the command-history boolean default.""" + from libtmux_mcp._history import _configure_history_defaults + from libtmux_mcp.tools import register_tools + + command_tools = {"run_command"} + spawn_tools = { + "create_session", + "create_window", + "split_window", + "respawn_pane", + } + 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}") + register_tools(mcp) + _configure_history_defaults(mcp, enabled) + async with Client(mcp) as client: + tools = await client.list_tools() + 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)) + changed = { + name + for name, schema in enabled.items() + if schema["default"] != disabled[name]["default"] + } + + 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 | spawn_tools: + schema = schemas[name] + assert schema["type"] == "boolean" + expected = default if name in command_tools else False + assert schema["default"] is expected + assert "anyOf" not in schema + if name in spawn_tools: + description = " ".join(schema["description"].split()) + assert description == SPAWN_SUPPRESS_PERSISTENT_HISTORY_DESCRIPTION + + +class SpawnEnvironmentFixture(t.NamedTuple): + """Fixture for successful spawn-environment preparation.""" + + test_id: str + environment: dict[str, str] | str | None + suppress_persistent_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_persistent_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_persistent_history=suppress_persistent_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", "correction"), + [ + ( + "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 + + from libtmux_mcp._history import _prepare_spawn_environment + + environment = {"UNCHANGED": "caller", name: supplied} + original = environment.copy() + expected = ( + f"environment variable {name} conflicts with " + "suppress_persistent_history=True; " + f"{correction}" + ) + + with pytest.raises(ToolError) as excinfo: + _prepare_spawn_environment(environment, suppress_persistent_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_persistent_history=False) + + assert str(excinfo.value) == "environment keys and values must be strings" + if isinstance(environment, dict): + assert environment == original + + +@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_scopes_startup_default_to_run_command( + test_id: str, + value: str | None, + expected: bool, +) -> None: + """Startup configuration never changes persistent-history spawn defaults.""" + 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"], + "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_persistent_history"]["default"] + for name in ( + "create_session", + "create_window", + "split_window", + "respawn_pane", + ) + }, + "spawn_descriptions": { + name: tools[name].inputSchema["properties"] + ["suppress_persistent_history"]["description"] + for name in ( + "create_session", + "create_window", + "split_window", + "respawn_pane", + ) + }, + }, 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"] + 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_PERSISTENT_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: + """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. 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") + 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) + + +@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: + """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))}" + ) + + async def _exercise() -> None: + async with Client(_history_server("1")) as client: + result = await client.call_tool( + "run_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(result) + + asyncio.run(_exercise()) + + assert output.read_text().splitlines() == [first, second] 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: diff --git a/tests/test_server.py b/tests/test_server.py index 023e406b..bcc8027a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -401,6 +401,39 @@ 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"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 + + +@pytest.mark.parametrize( + "suppress_history", + [False, True], + ids=["history-disabled", "history-enabled"], +) @pytest.mark.parametrize( ("tier", "tmux_pane", "tmux_env"), [ @@ -423,6 +456,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 +483,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 +494,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 ( + "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 + 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 ( + "suppress_history=false: run_command inherits; " + "raw send/batch/paste and spawn do not." + ) in instructions + assert "Agent context" not in instructions + + def test_base_instructions_document_scope() -> None: """``_BASE_INSTRUCTIONS`` carries an activation rule with anti-triggers. diff --git a/tests/test_spawn_shell_history.py b/tests/test_spawn_shell_history.py new file mode 100644 index 00000000..d562d204 --- /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_persistent_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_persistent_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..b6387bf5 --- /dev/null +++ b/tests/test_spawn_tools_history.py @@ -0,0 +1,828 @@ +"""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_persistent_history",), + ), + create_window: ( + ( + "session_name", + "session_id", + "window_name", + "start_directory", + "attach", + "direction", + "socket_name", + ), + ("environment", "suppress_persistent_history"), + ), + split_window: ( + ( + "pane_id", + "session_name", + "session_id", + "window_id", + "window_index", + "direction", + "size", + "start_directory", + "shell", + "socket_name", + ), + ("environment", "suppress_persistent_history"), + ), + respawn_pane: ( + ( + "pane_id", + "kill", + "shell", + "start_directory", + "environment", + "socket_name", + ), + ("suppress_persistent_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_persistent_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_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 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") + 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 + + 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 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_persistent_history=True; " + "omit it or set it to an empty string" + ) + 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_persistent_history=True, + ), + lambda: session_tools.create_window( + environment=environment, + suppress_persistent_history=True, + ), + lambda: window_tools.split_window( + environment=environment, + suppress_persistent_history=True, + ), + lambda: lifecycle.respawn_pane( + pane_id="%1", + environment=environment, + suppress_persistent_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_persistent_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_persistent_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_persistent_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_persistent_history=True, + ), + "HISTFILE", + ) + pane.refresh() + assert pane.pane_pid == original_pid + finally: + pane.kill() + + +def test_mcp_spawn_omission_ignores_enabled_command_default( + mcp_server: Server, + mcp_session: Session, +) -> None: + """The command-history default never enables persistent spawn controls.""" + 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: + omitted = await client.call_tool( + "create_window", + {**base, "window_name": "spawn_omitted"}, + raise_on_error=False, + ) + assert omitted.is_error is False + assert omitted.structured_content is not None + assert omitted.structured_content["window_name"] == "spawn_omitted" + + suppressed = await client.call_tool( + "create_window", + { + **base, + "window_name": "spawn_explicit_true_conflict", + "suppress_persistent_history": True, + }, + raise_on_error=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_omitted" + + +def _spawn_history_server(enabled: bool) -> FastMCP: + """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 + + mcp = FastMCP(f"spawn-history-{enabled}") + register_tools(mcp) + _configure_history_defaults(mcp, enabled) + return mcp + + +def test_generic_batch_spawn_ignores_command_default_unless_opted_in( + mcp_server: Server, + mcp_session: Session, +) -> None: + """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 = { + "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_omitted", + }, + }, + { + "tool": "create_window", + "arguments": { + **base, + "window_name": "batch_spawn_explicit_true_conflict", + "suppress_persistent_history": True, + }, + }, + ], + }, + 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 + 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_persistent_history=True; " + "omit it or set it to an empty string" + ) + assert supplied not in failed["error"] + 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_persistent_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_persistent_history=True; " + "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) + 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_persistent_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_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) + 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_persistent_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 = later_pane + 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_persistent_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_persistent_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_persistent_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_persistent_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_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_persistent_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_persistent_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()