You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Clarify that MCP Safety controls tool visibility, not OS-level confinement. #127
The title says it, but tmux can execute commands through formatters and send-keys, so that security assurances are really tool-level scoping for tmux operations, not OS-level commands. The scoping are tmux and MCP specific - tmux is still a raw command tool.
We should have a way to convey this clearly in the UX of the docs.
MCP / tmux: tmux operations
Note: Tmux, and AI agents themselves can still invoke and run shell commands.
it may be worth not phrasing it as safety / security at all and calling them tool types - as the surface / llm can already make commands.
Greenfield proposal for the capability model. This is deliberately the target state, not a unit of work — the Adoption section at the end is the bridge between the two, and it phases because one piece is a live defect and another lives in a second repository.
The tmux socket is the namespace boundary, not a permission system and not containment. Tools sit in four mechanical toolsets, carry honest per-tool annotations and a fixed opening sentence, and are described by three properties, all generated from one checked-in manifest. The control loop — run_shell_command completion, waiting, capture_since, history suppression, self-kill guards, audit log — is untouched; this document covers namespacing, permissioning, inventory, and disclosure.
Trust model
Three properties describe every tool. They are defined by the manifest, and the enabled surface is fixed at server startup.
Process reach: whether the tool can start a process or deliver client-controlled input to one. Values: none, configured-process (starts the pane's configured process; accepts no command payload and no caller-controlled tmux-format input), pane-input (client keys or text reach a pane's program), and pane-command (an authored shell command runs in a pane). A fifth value, host-command, exists only as a prohibition: no public tool may carry it.
tmux effect: observe, change, delete, or a set of them. respawn_pane is change + delete — it terminates the pane process and reinitializes the screen, discarding prior pane history — so a scalar would under-describe it.
Output classes: a set, not a scalar, because snapshot_pane returns terminal content and tmux metadata together and get_tmux_variables depends on what was asked for. Values: tmux-metadata, terminal-content, process-environment, configured-command, plus two conservative booleans, may_expose_secrets and may_return_untrusted_content. These are content classes, not severity: tmux-metadata still carries usernames, hostnames, paths, project names, and pane titles. Reads are not harmless; this property says so per tool instead of in a footnote.
The install statement, verbatim on the landing page and README:
This server gives an agent a terminal. By default it targets a
dedicated tmux server named libtmux-mcp, started with a minimal
configuration, separate from the tmux server your ordinary shell
uses. Once the agent has created its first session, attach to
watch; the server prints the exact command at startup.
Execute tools send input and run commands with your user's
authority. The socket separates tmux sessions, windows, and panes;
it does not restrict filesystem, process, or network access, and a
command run through a pane can open any other tmux socket by hand.
To target the tmux server you are sitting in, configure that
deliberately. Dropping the teardown toolset removes the direct
deletion tools; it does not prevent an execute tool from typing an
equivalent command. Toolset filtering shapes this server's
advertised interface, not the authority of tmux or of the pane
program.
Annotations and descriptions are disclosure for client consent
machinery, not containment. OS accounts, containers, socket
selection, and tmux configuration are the isolation boundaries.
Client-side, stable names are the policy hooks: a client can auto-allow inspect tools by name, prompt on execute, and always confirm teardown, because every tool name is literal and deterministic. One qualification, stated rather than glossed: call_read_tools_batch aggregates authority under its own name, so a client that denies show_environment but allows the batch can still have show_environment invoked. Client policy must read that tool as authority to invoke every tool in its schema enum. Pane-input and pane-command tools are advertised destructive because what they carry can reverse poorly; a client policy that blocks destructiveHint blocks typing, and that is a correct decision made on accurate information.
The socket is the namespace boundary
tmux already ships a namespace: the server socket. Use it instead of simulating one, and claim only what it does. It isolates tmux object worlds, so structured tools cannot mis-target your attached sessions; it does not confine what an executed command can reach, other sockets included. Accident isolation for the tool surface, and the documentation never says more.
The server pins to exactly one socket for its lifetime, set by LIBTMUX_SOCKET. Three selectors, explicit so that no socket name is reserved by the syntax — a server genuinely named inherit is name:inherit:
name:<name>, passed as -L name. Default name:libtmux-mcp, product-scoped so two different MCP servers with generic defaults never share a bus by accident. default is just the name of tmux's stock socket and carries no special meaning here.
inherit: pass no socket flag; normal tmux selection applies in the MCP process's own environment, including $TMUX when that process runs inside tmux. A desktop client launching the server outside tmux inherits nothing and falls back to ordinary default selection, so inherit is not a synonym for "the session you are sitting in".
path:/abs/socket: passed as -S, preserving explicit socket paths, which the current project supports and which the capability model has no reason to remove.
Acceptance tests cover Linux and macOS with TMUX_TMPDIR set and unset. Two instances of this server on one uid sharing a name share a world, so one hot agent per socket; a second agent gets its own name. No tool takes a socket parameter; working on two sockets means two server entries in the client config, named apart, teardown never enabled on the live one.
Configuration is part of the ambient execution context, not of the namespace. A stock new server on a fresh socket still reads the user's tmux configuration, which is itself a command stream: hooks, plugins, status jobs, and default-command all ride in. So when this server is the one that starts the tmux server, on any non-inherit socket, it starts it with a shipped minimal configuration, making the agent workspace deterministic and hook-quiet by default. LIBTMUX_TMUX_CONFIG overrides with a path or the value user. Configuration provenance is fixed when a tmux server first starts, not by how a later client selects the socket, so inherit may reach a server started from user, custom, package-managed, or unknown configuration. LIBTMUX_TMUX_CONFIG therefore governs only a server this invocation actually starts. When the pinned socket's server already exists, startup discloses it: server_state=existing, configuration_provenance=unknown. Startup must also define how concurrent creation is serialized per resolved socket path, and whether an existing server of unknown provenance is adopted or refused.
Watching the agent is one command, and the server generates it rather than documenting a static string, because -L resolves a name under TMUX_TMPDIR and a desktop-launched server and an interactive shell can disagree about that:
$ tmux -N -S /tmp/tmux-1000/libtmux-mcp attach
-N is load-bearing. Plain attach may start an absent server, and a user who runs the watch command before the agent creates its first session would start the socket from their own configuration, leaving the package to find a server of unknown provenance. -N refuses instead, and is available across the supported tmux matrix. -S with the resolved path sidesteps TMUX_TMPDIR disagreement entirely. Startup output and tmux://capabilities both carry resolved_socket_path and a shell-quoted attach_command.
Server-absence semantics are typed, not blurred: plural discovery (list_sessions, search_panes) returns empty collections; get_server_info returns running=false with the socket; singular targets (get_session_info, capture_pane, snapshot_pane) return a typed not-found error; wait_for_text fails immediately when its pane does not exist. An empty capture means the pane exists and has no text, never that there is no server. create_session starts the server; by default tmux exits when no sessions remain, though exit-empty can change that, at which point the operator broom is tmux -L libtmux-mcp kill-server.
The self-kill guards carry over with honest scope: they protect the pane, window, and session containing this server process, only when the process lives inside the pinned socket, and only against the direct teardown tools. A desktop client spawning the server outside tmux gets no guard, and on the dedicated default the guard is idle for a better reason: the pane it exists to protect sits on a server the structured tools cannot reach. The guard earns its keep on inherit.
Toolsets and defaults
Four toolsets follow mechanical function. They exist for inventory configuration, context reduction, documentation navigation, and model routing. They are never called permissions, scopes, or safety levels. They are unordered sets replacing an ordered ladder, and that is the mechanical win: the old tiers accumulated upward, so kill tools could never be enabled without the typing tools, while LIBTMUX_TOOLSETS=inspect,teardown is now a legal surface.
inspect. Read tmux state and terminal output. Process reach none. Client input is IDs, names, bounded patterns, and validated variable identifiers.
manage. Change tmux structure or presentation. Process reach none. No client-supplied executable input of any kind. wait_for_channel and signal_channel sit here rather than in inspect because tmux wait-for registers channel state on the server, where wait_for_text only reads.
execute. Start or drive pane processes. The product lives here.
teardown. Delete tmux objects or retained scrollback. Irreversible at the tmux level.
Default: all four enabled. The socket default is what makes this cheap: out of the box the agent's objects are separated from the user's ordinary tmux server, and an agent that creates scratch sessions should be able to remove them. Hiding kill_session while run_shell_command is enabled would not be containment anyway; the typed tool with an exact target, a guard, and an audit record is strictly better than the shell workaround it cannot prevent. The one rule when sharing a socket, drop teardown, appears in the install statement, the shared-socket recipe, and the trust page. What the default socket does not claim: objects on it are shared by every process that connects, so a server left by a previous run, a session the user made by hand, or a second concurrent instance are all reachable. Exclusive ownership would need an advisory lease keyed to the resolved socket path plus an explicit adoption setting for a server of unknown provenance; until that ships, the honest sentence is separation from the user's world, not sole ownership of this one.
set_synchronize_panes sits in execute despite issuing an ordinary window option, because toolsets exist to aid client policy rather than to mirror the tmux command. It is an input amplifier: once enabled, one approved send_keys reaches every pane in the window, so a client that auto-allows manage would widen a later prompt's real target set after the user read it. Its manifest row also carries amplifies_future_input, and send_keys reports the actual resolved target set in its result and audit record.
Naming is part of the consent surface. run_command becomes run_shell_command, because in a tmux project a command could mean a tmux command, an MCP tool, or a shell command, and the name appears verbatim in permission rules, transcripts, prompts, and audit records. clear_pane becomes clear_pane_scrollback, naming exactly what is lost, and moves to teardown, which leaves manage holding only non-deleting state changes. display_message becomes get_tmux_variables. show_hook folds into show_hooks via an optional name.
Spawns carry no payloads, and that claim has one more condition than parameter removal. create_session, create_window, and split_window accept no command string, no shell string, and no environment mapping; they start the pane's configured process and return IDs. But start_directory survives, and tmux runs the -c value through format_single (spawn.c, cmd-new-session.c), where a #(...) job executes through /bin/sh. Verified against tmux 3.7d: tmux new-session -d -c '#(touch FILE)' creates the file. So the commandless claim holds only once every format-bearing input is constructionally literalized — resolve and validate the path in Python and escape tmux format metacharacters, or set the tmux subprocess's own cwd and omit -c. Until that lands, these tools carry a caller-controlled tmux-format sink and cannot advertise configured-process reach or destructive: false. run_shell_command is the canonical authored interactive-shell command surface, called next — not the only client-authored execution route, since send_keys, paste_text, and respawn_pane also carry authored input. The extra call buys a separate approval event, one command path to review, no spawn-time secrets in the process table, no environment-redaction special cases, and spawns whose honest annotation is additive. respawn_pane keeps its optional replacement command because process replacement is its entire purpose, so the one-surface claim is precisely one client-authored command surface plus that stated, annotated exception. The spawn-then-run sequence is not a new race: it is already this product's canonical documented flow, and run_shell_command's completion protocol turns a shell that swallows early input into a visible timeout rather than silent corruption, with fresh-pane readiness a named implementation requirement. Exec-as-pane-process, where the command is the pane and no shell is involved, stays expressible by typing tmux new-window with a command through run_shell_command, on the surface labeled execution. That workaround has a stated cost: the audit record says a shell command ran, not that this exact pane process was spawned, and the new pane ID takes a second discovery step. If that cost proves too high in practice the answer is explicit spawn_*_process tools in execute taking a typed argv list — not an optional command parameter back on the additive creation tools.
Two typed setters carry mandatory description lines beyond their openers: set_synchronize_panes states that it duplicates subsequent pane input to every pane in the window, amplifying what one send_keys reaches; set_history_limit carries bounded integers and a note that retained-history behavior is version-tested before the tool claims to be loss-free.
call_read_tools_batch survives because every inner call is readOnly, so its own annotation is honest and it is a real round-trip win. It does not preserve per-inner client approval, and the document does not claim otherwise: a client must treat it as authority over every tool in its schema enum. That enum is the batch-eligible inspect set — excluding itself, excluding the self-bounded and blocking tools, and excluding show_environment unless deliberately included — capped at sixteen operations and an aggregate response size, with one child audit event per operation and the worst-case openWorld of any eligible member. Excluding the wait tools is what makes the deadline question disappear; sharing one aggregate deadline across nested waits is implementable but it is a new control-loop feature with its own cancellation semantics, not carried-over behavior. The general batch does not survive, see below.
Not carried over
kill_server. Redundant for the agent: killing its sessions one by one empties the server, and tmux by default exits with them. The socket-wide wipe belongs to the operator, as does wedged-server recovery and any exit-empty configuration. It was the one tool that turned a forgotten shared-socket line into losing an entire attached tmux, and a typed version with a schema makes that accident easier to reach, not harder.
Spawn command and environment parameters, per above.
call_tools_batch and the mutating and destructive batches. A generic wrapper defeats the design's own consent layer: client policy keys on stable tool names, and a batch gives every inner operation the wrapper's name, so an always-confirm rule on kill_session never fires inside an allowed batch, and one outer approval is all the protocol guarantees the client will render. Purpose-built composites — snapshot_pane, capture_since, run_shell_command, send_keys_batch — each do one kind of thing under one honest name; arbitrary composition is the client's job.
set_option and set_environment. A generic value can carry formats, commands, hooks, or escape sequences, and an allowlist is a second tmux policy database that must be argued forever. Typed setters with closed schemas ship where a workflow earns them, three at launch, and everything else goes through run_shell_command, where changing tmux configuration is advertised as what it is: execution. Per-command environment is the env VAR=value command prefix on that same surface.
pipe_pane. The one tool whose command was not represented by a pane and ran outside the observable command-completion loop. Cutting it is what makes the no-host-side-pipelines sentence true.
load_buffer, paste_buffer, show_buffer, delete_buffer. Agents type. paste_text may use a server-namespaced buffer internally, cleaned up on success and ordinary failure.
list_servers and every per-tool socket parameter.
Dynamic pane-content resources. Pane content flows through explicit tools with tool-approval semantics. One static resource remains, tmux://capabilities, and it reports the effective startup-filtered surface, not the unfiltered source manifest.
Payload inspection on typed input. Deliberately absent, see below.
Configuration
Configuration variables, each mirrored by a CLI flag.
LIBTMUX_SOCKET. Bare name, inherit, or path:/abs/socket, per the semantics above. Default libtmux-mcp.
LIBTMUX_TMUX_CONFIG. Configuration for any tmux server this process itself starts, on every non-inherit socket: minimal by default, a path, or user.
LIBTMUX_TOOLSETS. Comma list. Default inspect,manage,execute,teardown. May be empty.
LIBTMUX_TOOLS. Individual tools to enable regardless of toolset.
LIBTMUX_EXCLUDE_TOOLS. Individual tools to disable. Beats every enable.
LIBTMUX_SUPPRESS_HISTORY and the wait ceiling, unchanged.
Selection: expand the toolsets, union the tools, subtract the exclusions. Unknown toolset or tool names fail startup with an error naming the valid set, because silent fallback is how a typo becomes a capability grant. The surface is resolved once at startup, is deterministic in order, and no runtime tool can reconfigure it: no MCP call can change the current process's effective registered tool inventory. An execute-capable agent can still edit configuration that a later restart would read. LIBTMUX_READ_ONLY is either shipped as a strict floor that overrides LIBTMUX_TOOLS, or not reserved at all. It is not a duplicate of LIBTMUX_TOOLSETS=inspect, because LIBTMUX_TOOLS can union an execute tool back into that surface, and a reserved name with undefined precedence is worse than no name.
The dedicated socket is correct namespacing and, unannounced, looks like a broken install: the user watches their terminal and nothing happens there. Two treatments, both mandatory.
The quickstart leads with two equal cards, not one default and one buried recipe: the agent's own terminal (default, zero config, attach to watch) and drive the session you are in (inherit, teardown dropped, execution still on and said so). Neither is the advanced option.
The server instructions tell the agent to tell the user. One line in the budget: operating on tmux socket libtmux-mcp; the user can watch with tmux -L libtmux-mcp attach; say so when starting work.
Manifest
Every tool is one row in one checked-in table, and registration, filtering, docs, badges, the README inventory, and the capabilities resource all generate from it. A row carries the name, the title (emitted as the top-level tool title), toolset, tmux effect, process reach, output sensitivity, the four MCP behavioral hints as explicit booleans, an internal input-sinks column, the opening sentence key, a self-bounded flag, and the default-enabled flag. Self-bounded survives the batch cut because the fact has other consumers: the retry middleware reads it to decide what is safe to re-invoke, and the wait tools carry it for deadline accounting, so it lives in the manifest rather than dying with the feature that first motivated it.
destructiveHint is used strictly as the protocol defines it: false asserts the tool performs only additive updates, and true is the cautious default. An earlier draft of this document set destructive: false across manage on the grounds that inflating the hint onto renames and resizes teaches users to stop reading prompts. That is a prompt-profile argument overriding a protocol claim, which is the exact conflation this redesign exists to end, so it is withdrawn. Rename, resize, select, move, swap, replace-layout, replace-title, toggle-mouse, and replace-history-limit are not additive — rename_window additionally disables automatic renaming for that window — and all of them advertise destructive: true. Only pure observation, additive creation with no replacement flag and no executable or format-bearing payload, and unique buffer staging keep false.
That leaves destructiveHint a coarse read/write split, which is the protocol's own resolution and not this project's to widen. The discrimination a client actually needs is carried by toolset, process reach, and the opening sentence: rename_window is manage / change / reach none, run_shell_command is execute / change / pane-command. A client that understands only the standard hints behaves conservatively, and that is the correct outcome.
The openWorldHint question is decided rather than left open: terminal-content reads ship openWorld: true. A pane can hold output from SSH, package managers, log streams, test fixtures, and remote agents, and the hint concerns both where a tool reaches and what its output carries back. Marking capture_pane, capture_since, snapshot_pane, search_panes, and wait_for_text open-world tells a client that returned text crossed a trust boundary. Structural metadata, options, hooks, environment, and direct teardown stay closed.
The internal input-sinks column records where each client parameter lands: none, tmux lookup, tmux state value, pane input, shell command, process argv, python regex, or tmux-format. That last value is the one the start_directory finding forced: tmux expands formats in argument positions whose names give no hint of it, so a sink table is the only durable form of this invariant and a schema field-name filter is not. Two further columns, amplifies_future_input and may_trigger_configured_hooks, record facts the three public properties cannot express — the first for set_synchronize_panes, the second because tmux runs after-hooks on nearly any command, so annotations describe the direct operation this server issues and never the ambient configuration around it. It is not exported; it exists so CI can assert sink-level facts. The three public properties additionally ship as namespaced custom metadata, reverse-DNS keys under com.git-pull.libtmux-mcp/, which clients may ignore; nothing depends on them.
Eleven invariants, enforced in CI as property tests over the manifest and the registered server
Registered tool names equal manifest names, one to one; all four behavioral hints are explicit booleans and every tool has an explicit title.
Every inspect tool: readOnly: true, reach none, and no pane-input, shell-command, process-argv, process-environment, or caller-controlled tmux-format sink. Asserted against the sink table, never against schema field names. Every readOnly tool sits in inspect.
No public tool has host-command reach; startup prints host_command_tools=0. No public schema contains socket, socket_name, or socket_path.
Every pane-input and pane-command tool: destructive: true, idempotent: false, openWorld: true. Every non-additive state update: destructive: true. Every additive creation: destructive: false only once every caller-controlled format and execution sink is eliminated. Every teardown tool: destructive: true, openWorld: false. Terminal-content reads: openWorld: true. No tool advertising that it accepts no executable input may expose a caller-controlled tmux-format sink. A destructive: false anywhere requires a linked per-tool test asserting the operation is observational or additive; CI enforces the test's presence, review enforces its honesty.
Each description begins with its manifest opener, exactly, and the two setters with mandatory extra lines carry them.
Excluded tools are absent from the tool list and rejected if invoked directly. All sixteen toolset combinations produce exactly the expected surfaces. Order is deterministic.
call_read_tools_batch excludes itself, every self-bounded or blocking tool, and every tool whose output classes exceed its own advertised worst case; its enum is the batch-eligible inspect set, its operation count and aggregate response size are capped, and it emits one child audit event per inner call.
get_tmux_variables rejects anything outside the variable-name grammar; search patterns carry size bounds and a bounded-time matching mechanism; temporary paste buffers are namespaced and cleaned up.
Command-bearing arguments are redacted in audit records and in validation-failure messages alike.
Generated README inventory and tool pages match the manifest, and tmux://capabilities reports the startup-filtered surface.
A repository lint rejects new affirmative sandbox or containment claims — safety tier, safety level, secure mode, safe command, sandboxed, no shell — while permitting negative boundary disclosures such as "not an OS sandbox", and exempting MIGRATION and CHANGES, which must name what they replace. Copy cannot drift back toward guarantees the implementation does not make.
Controlled openers
The first sentence of every description is generated from process reach and tmux effect. It is the shell-reach vocabulary the protocol lacks, placed where models and permission prompts already read:
inspect, metadata: Inspect tmux metadata; accepts no client-supplied executable input.
inspect, pane output: Read pane output; accepts no client-supplied executable input. Returned content may be sensitive or untrusted.
inspect, environment: Read the tmux environment; accepts no client-supplied executable input. Returned values may contain secrets.
inspect, configuration: Read configured tmux commands; accepts no client-supplied executable input. Returned values may contain executable configuration.
manage: Change tmux state; no client-supplied executable input.
configured-process spawn: Start a pane's configured process; accepts no command payload.
pane-input: Send input to a pane's program; a shell that receives it runs it with your user's permissions.
pane-command: Run a shell command in a pane with your user's permissions.
teardown: Delete tmux state; accepts no command payload.
Routing guidance follows in the existing docstring voice, steering models toward the calls that do more per approval: run_shell_command folds type, wait, and capture into one call, send_keys_batch folds ordered input into one. One invocation, one typed result, one completion boundary — whether and how a client confirms it is the client's decision, not something the protocol guarantees.
The inspect claim
The claim is about interpretation of client input, not about the absence of processes, because tmux is a programmable environment: configured hooks can fire on nearly any tmux command. The minimal default configuration keeps the dedicated workspace hook-quiet; on inherit and user-configured sockets the caveat is load-bearing. Stated on the trust page:
inspect tools do not interpret client-supplied values as commands,
keystrokes, process-launch strings, environment assignments, option
values, or free-form tmux formats. Client input is used only for
targeting, filtering, bounded matching, and selecting validated
variable identifiers. This is a tested property of these implementations,
not a claim that no process can run: tmux hooks and configuration
act independently, and inspect results can contain secrets visible
to the tmux server.
get_tmux_variables is the constructional half: it takes a capped list of variable names matching ^[A-Za-z][A-Za-z0-9_]*$ and issues one tmux invocation per variable, so returned values can never be confused with a delimiter and the guarantee is provable rather than parsed. A user who genuinely needs raw format syntax runs tmux display-message -p through run_shell_command, where it is advertised as execution, which is where it belongs.
search_panes names its mechanism instead of gesturing at one: a bounded-time regex engine (the regex module's timeout or an RE2 binding, fixed at implementation) plus pattern-length caps. A length cap alone is not a time ceiling and is never claimed to be.
Disclosure surfaces
Server instructions carry, inside the existing byte budget: the pinned socket and the tell-the-user attach line, the enabled toolsets, one sentence that execute tools run commands with this user's permissions, one sentence that tool filtering is not an OS sandbox and does not constrain other MCP tools, the routing preferences, and the caller-pane context when inside tmux, including the cross-socket case: when the agent's own pane lives on a different server than the pinned socket, the instructions say so and note that these tools cannot reach that pane.
Startup prints one record: version, socket and its form, whether the tmux server pre-existed and the configuration provenance, toolsets, tool count, command_capable, host_command_tools=0, plus a warning line when the socket resolves to an inherited shared server. tmux://capabilities returns the socket, provenance, and effective enabled surface machine-readably.
Audit events carry tool, toolset, tmux effect, process reach, duration, outcome, client and request IDs, resolved target IDs, and a redacted argument summary; executable payloads appear as length plus a digest keyed with a per-process random secret, because an unkeyed sha256 prefix of a short command is a dictionary lookup away from plaintext, and calling that redaction would be the exact overclaim genre this design exists to remove; results are never copied in. The trust page scopes both honestly: audit redaction covers the audit record only, and history suppression is history hygiene, not secret transport.
One trust-model page replaces any safety page: the install statement, the three properties, the socket and configuration scope, the inspect claim, the destructiveHint division of labor, what toolset filtering does and does not bound, output sensitivity, guard scope, and OS and container guidance. Tool pages generate from the manifest and docstrings.
What is not built
A named safety level, tier, mode, or ordered enum. Three per-tool properties and four mechanical toolsets replace the hierarchy that had to lie about one axis to speak the other.
Payload inspection or denylists on send_keys and run_shell_command. That race is unwinnable, and a filter that sometimes wins teaches operators it always does.
A generic option setter, environment setter, formatter evaluator, or hook writer. Typed setters where earned; the rest through the surface labeled execution.
A generic batch wrapper. It launders every inner name past name-keyed client policy, and the protocol guarantees only one outer confirmation.
Internal consent prompts, role engines, or runtime capability escalation. The client owns consent; the OS owns containment; this server owns telling the truth.
Sandbox language anywhere, enforced by lint.
A separate observer binary or a redundant read-only switch. One binary, LIBTMUX_TOOLSETS=inspect.
Conditional defaults. The same defaults everywhere; two quickstart cards, not heuristics, adapt them.
Carried over unchanged
run_shell_command's completion protocol, wait_for_text and wait_for_channel, capture_since cursors, snapshot_pane, typed IDs and targeting, correctable error results, history suppression, the self-kill guards within their stated scope, digest-redacted audit records, tail-preserving output limits, and the instructions byte budget. The redesign is namespacing and advertisement; the control loop is the product and already correct.
Adoption
The alpha banner licenses hard breaks, but the work phases, because one piece is a live defect and another lives in a second repository.
Phase zero ships on the current alpha immediately, independent of everything else, and it is a per-tool audit rather than a flip of shared constants.ANNOTATIONS_SHELL advertises destructiveHint: false on seven tools that do not behave alike: send_keys, send_keys_batch, run_command, paste_text, pipe_pane, and paste_buffer all deliver input to a program, while load_buffer shares the preset yet only stages content into a newly allocated buffer — the module's own docstring says the preset is for "commands about to be pasted into a shell", so the code already contradicts its stated rationale. ANNOTATIONS_MUTATING claims additive, idempotent behavior for setters that overwrite. split_window is the worst row: it accepts a shell command and an environment mapping while registered with ANNOTATIONS_CREATE, advertising destructiveHint: false and openWorldHint: false. In the spec's vocabulary, destructive: false is a positive claim of additive-only updates and true is the default being overridden, so all of this is a standing misstatement to every connected client and does not wait for the redesign.
Phase zero also closes the format sink, because it is live in shipped code and not merely a flaw in this proposal: start_directory reaches tmux unvalidated on create_session, create_window, split_window, and respawn_pane. display_message rejects #(; these four have no equivalent guard. Terminal-content reads get their openWorldHint decision in the same pass.
Then, in order:
The manifest over the current inventory — annotations, process reach, effects, output classes, input sinks including tmux-format, retry policy, batch eligibility, and hook activation — with only the CI-checkable invariants, and an enumeration of every consumer of the legacy tags before any tag changes. Its rows are written against current names and migrate in lockstep with the later phases.
The badge vocabulary in gp-sphinx — repo, PyPI — which hardcodes the three old tier names across its collector, badge, and CSS layers and silently renders unknown tags as readonly, so it grows a configurable vocabulary, releases, and the docs pin bumps before the rename lands here.
The vocabulary and toolset switch, middleware, environment variables, instructions, and LIBTMUX_SAFETY deleted with a startup error naming LIBTMUX_TOOLSETS. The default here is the behaviourally equivalent inspect,manage,execute — not the four-toolset default — because the socket still resolves to the user's ordinary tmux world at this point, and enabling teardown against it is precisely the accident the dedicated namespace exists to prevent.
Naming and the approved inventory removals, with MIGRATION carrying an explicit old-to-new tool name map and docs redirect entries, because the stable-names argument is a promise about the future, not the past.
The pinned socket and configuration model: the name: / path: / inherit selectors, per-process pinning, configuration provenance and the minimal default configuration, no-start read probes, generated attach command, and adoption behaviour for an existing server. This phase has to precede the removal of list_servers, every socket_name parameter, and the socket-bearing resource templates — those cannot land before the server knows how to pin one socket at startup. The process default may stay inherit for one alpha.
The docs prose, trust-model page first, with the old safety topic redirecting to it, because shipped READMEs and this issue link there. Each behavioural phase above ships its own trust disclosures; this phase is the consolidation, not the first time any of it is written down.
The socket default and the teardown default flip together, last, in one release, after the two-card quickstart, the generated attach command, and the tell-the-user instruction line exist. The atomicity is the point: default socket: name:libtmux-mcp and default toolsets: inspect,manage,execute,teardown are only jointly defensible, since teardown-by-default is cheap exactly because the socket is the agent's own. Either alone is a regression. The quickstart treatments are the mitigation for the empty first look, and shipping the flip before them ships the confusion without the cure. That release's migration note leads with it: upgraded installs see an empty world on libtmux-mcp, running a minimal configuration, until the operator attaches there or sets LIBTMUX_SOCKET=inherit. MIGRATION records the rest of the mapping: readonly becomes LIBTMUX_TOOLSETS=inspect, mutating becomes inspect,manage,execute, destructive becomes the default, the old socket behavior becomes inherit, and kill_server, set_option, set_environment, pipe_pane, and the generic batches no longer exist.
Intended to land as docs/dev/adr/0001-capability-model.md. The full target-state design, including the inventory, manifest columns, CI invariants, and adoption phases, is the capability model comment on this issue. This record is the altitude above it: what we are committing to, and what we are refusing to promise.
Status
Proposed. Supersedes the LIBTMUX_SAFETY three-tier model.
Context
libtmux-mcp hands an agent a terminal. Everything downstream of that — which tmux objects it can touch, what a client should prompt on, what a reader should believe — depends on describing that capability accurately.
One ordered scale cannot do it. Running a shell command and deleting a tmux window are different powers, not different amounts of one power, and a ladder that ranks them forces every tool to be described by its position rather than its behaviour. The practical symptom is that a rung has to lie about one axis to speak about the other, and the honest statement a user needs — this can execute code as you — ends up implied by a tier name instead of stated in the tool's own description.
The second problem is that the words available are not ours alone. MCP defines readOnlyHint, destructiveHint, idempotentHint, and openWorldHint with specific meanings and explicitly frames them as untrusted hints for client presentation, not authorization. A server that repurposes them as severity labels is not communicating with clients; it is corrupting a shared vocabulary.
Decision
CM-1 — The tmux socket is the namespace boundary, and it is only that. A server process pins one socket for its lifetime. That scopes which tmux objects the structured tools can name, which is genuine accident isolation for the tool surface. It scopes nothing about the filesystem, processes, network, or other sockets, and the documentation never implies otherwise.
CM-2 — Capability is described by independent properties, not a rank. Process reach (can this start a process or deliver client-controlled input to one) and tmux effect (observe, change, delete) vary independently, and output classes describe what a result can carry back. rename_window and run_shell_command both change tmux state; only one can run code. A model with two axes says that in one line, and a ladder cannot say it at all.
CM-3 — Toolsets are unordered inventory sets, resolved once at startup.inspect, manage, execute, teardown. They shape what this server advertises, for context reduction, model routing, and documentation navigation. Because they are sets rather than a cumulative scale, inspect,teardown is expressible; under the old ladder, deletion tools could not be enabled without the typing tools. Tag-based inventory filtering is also how the surrounding ecosystem already works — FastMCP's own config layer exposes include_tags / exclude_tags over the same mechanism.
CM-4 — Standard MCP annotations are used strictly as the protocol defines them.destructiveHint: false asserts additive-only updates and true is the cautious default, so replacement operations advertise true even when that produces a coarser prompt profile than we would like. Nuance is carried by our own vocabulary — toolset, process reach, output classes, and a generated opening sentence — never by bending a shared hint. A client that understands only the standard hints behaves conservatively, which is the correct outcome.
CM-5 — One checked-in manifest is the single source of truth, and CI asserts against sinks rather than names. Registration, filtering, generated docs, badges, the README inventory, and the capabilities resource all derive from one table. Invariants are asserted on where a parameter's value lands in the callee, not on what the parameter is called: tmux expands formats in argument positions whose names give no hint of it, so a field-name filter would pass a shell sink named start_directory.
CM-6 — There is one canonical surface for client-authored shell commands, and none for host commands.run_shell_command runs authored commands in a pane, where they are attachable, observable, and tied to a completion protocol. No public tool runs client-authored code outside a pane. This is an architecture and observability claim, not confinement.
CM-7 — Disclosure is a product surface, held to the same standard as behaviour. The install statement, the generated opening sentences, the startup record, the trust-model page, and the audit record are all deliverables, and a repository lint rejects new affirmative sandbox or containment language while permitting negative boundary disclosures.
What this does not guarantee
Each of these is stated because a reader could otherwise reasonably infer it.
Not containment. Execute tools run commands with the user's full authority. A command running in a pane can reach any file, process, or network the user can, and can open any other tmux socket by hand. OS accounts, containers, and VMs are the isolation boundary; this server is not one. We do not build a sandbox because a server that cannot confine its own child processes cannot honestly claim to.
Toolset filtering is not authorization. Dropping teardown removes the direct deletion tools from the advertised inventory. It does not stop an enabled execute tool from typing the equivalent tmux command. We ship it as inventory configuration and accident reduction, and refuse to describe it as a permission system, because a bypass that is one send_keys away is not a boundary.
Annotations describe the direct operation this server issues, never the ambient tmux around it. tmux is programmable: roughly half of its command entries declare CMD_AFTERHOOK, and a hook can run-shell. So a nominally read-only call can trigger a configured mutation on an inherited or user-configured server. The manifest records hook activation separately; the hints stay a statement about our own issued command, and on unknown configurations clients must not read them as a claim about ambient behaviour.
The dedicated socket is separation, not exclusive ownership. Objects on it are reachable by every process of the same user that connects — a server left by a previous run, a session made by hand, a second concurrent instance. Exclusive ownership would need an advisory lease and an explicit adoption step for a server of unknown provenance. Until that exists we say separation from the user's ordinary tmux world, and never "only this agent's objects."
inspect means "does not interpret client input as executable", not "safe". Inspect tools can return credentials, command lines, environment values, and terminal output containing prompt-injection text. Terminal-content reads are therefore advertised open-world. Blanket auto-approval of the whole toolset is a client's decision to make with that stated, not something the name endorses.
No payload inspection on typed input. We do not scan send_keys or run_shell_command for dangerous content. That race is unwinnable, and a filter that catches enough examples to look protective is worse than none, because it teaches operators to rely on it.
Bounded matching is a mechanism we must supply, not something the language gives us. CPython's re has no execution timeout, so pattern-length caps alone are not a time ceiling. Search bounds mean a specific bounded-time engine, named at implementation, or the guarantee is not made.
Redaction and history suppression are scoped. Audit redaction covers the audit record; it does not rewrite shell history, client transcripts, pane scrollback, process arguments, or OS observation surfaces. History suppression is best-effort hygiene, not secret transport.
Aggregate calls do not preserve per-inner client approval. The single read batch that survives aggregates authority under its own name, so a client policy keyed on an inner tool's name will not fire. That is stated in the tool's own description rather than papered over, and it is why the mutating and destructive batches do not survive at all.
Names are not yet stable. The model depends on literal tool names as client policy hooks, which makes the alpha's renames a one-time break rather than a free change. MIGRATION carries the explicit old-to-new map; the stability promise begins after this lands, not before.
Self-kill guards protect one process against the direct teardown tools. They cover the pane, window, and session containing this server, only when it lives on the pinned socket, and only against those typed tools — not against an equivalent command typed through an execute tool, and not at all for a server launched outside tmux.
Consequences
The honest annotation policy costs prompt granularity: many manage tools become destructive: true, and clients that gate on that hint will prompt more. That is accepted. The signal a client needs to distinguish a rename from a shell command moves into toolset, reach, and the opening sentence, which are ours to define and are visible in the standard description field rather than in metadata a client may ignore.
Removing per-call socket arguments means two tmux servers require two configured MCP entries. Deriving everything from one manifest means adding a tool is adding a row plus a test, and a tool whose claims drift from its behaviour fails CI rather than shipping.
The changes span two repositories: the documentation extension hardcodes the old tier vocabulary and silently renders unknown tags as readonly, so it has to grow a configurable vocabulary and release before any tag rename lands here.
Because exit-empty defaults on (options-table.c), an agent that removes its own sessions empties its server without a socket-wide kill tool, which is why none is offered.
The result is a server that is useful by default, explicit that it can execute arbitrary code, precise about the little the socket actually scopes, and structured so that a future tool cannot quietly acquire authority the documentation does not admit to.
Activity
tony commentedon Aug 29, 2026
Greenfield proposal for the capability model. This is deliberately the target state, not a unit of work — the Adoption section at the end is the bridge between the two, and it phases because one piece is a live defect and another lives in a second repository.
The tmux socket is the namespace boundary, not a permission system and not containment. Tools sit in four mechanical toolsets, carry honest per-tool annotations and a fixed opening sentence, and are described by three properties, all generated from one checked-in manifest. The control loop —
run_shell_commandcompletion, waiting,capture_since, history suppression, self-kill guards, audit log — is untouched; this document covers namespacing, permissioning, inventory, and disclosure.Trust model
Three properties describe every tool. They are defined by the manifest, and the enabled surface is fixed at server startup.
Process reach: whether the tool can start a process or deliver client-controlled input to one. Values:
none,configured-process(starts the pane's configured process; accepts no command payload and no caller-controlled tmux-format input),pane-input(client keys or text reach a pane's program), andpane-command(an authored shell command runs in a pane). A fifth value,host-command, exists only as a prohibition: no public tool may carry it.tmux effect:
observe,change,delete, or a set of them.respawn_paneischange + delete— it terminates the pane process and reinitializes the screen, discarding prior pane history — so a scalar would under-describe it.Output classes: a set, not a scalar, because
snapshot_panereturns terminal content and tmux metadata together andget_tmux_variablesdepends on what was asked for. Values:tmux-metadata,terminal-content,process-environment,configured-command, plus two conservative booleans,may_expose_secretsandmay_return_untrusted_content. These are content classes, not severity:tmux-metadatastill carries usernames, hostnames, paths, project names, and pane titles. Reads are not harmless; this property says so per tool instead of in a footnote.The install statement, verbatim on the landing page and README:
Client-side, stable names are the policy hooks: a client can auto-allow
inspecttools by name, prompt onexecute, and always confirmteardown, because every tool name is literal and deterministic. One qualification, stated rather than glossed:call_read_tools_batchaggregates authority under its own name, so a client that deniesshow_environmentbut allows the batch can still haveshow_environmentinvoked. Client policy must read that tool as authority to invoke every tool in its schema enum. Pane-input and pane-command tools are advertised destructive because what they carry can reverse poorly; a client policy that blocksdestructiveHintblocks typing, and that is a correct decision made on accurate information.The socket is the namespace boundary
tmux already ships a namespace: the server socket. Use it instead of simulating one, and claim only what it does. It isolates tmux object worlds, so structured tools cannot mis-target your attached sessions; it does not confine what an executed command can reach, other sockets included. Accident isolation for the tool surface, and the documentation never says more.
The server pins to exactly one socket for its lifetime, set by
LIBTMUX_SOCKET. Three selectors, explicit so that no socket name is reserved by the syntax — a server genuinely namedinheritisname:inherit:name:<name>, passed as-L name. Defaultname:libtmux-mcp, product-scoped so two different MCP servers with generic defaults never share a bus by accident.defaultis just the name of tmux's stock socket and carries no special meaning here.inherit: pass no socket flag; normal tmux selection applies in the MCP process's own environment, including$TMUXwhen that process runs inside tmux. A desktop client launching the server outside tmux inherits nothing and falls back to ordinary default selection, soinheritis not a synonym for "the session you are sitting in".path:/abs/socket: passed as-S, preserving explicit socket paths, which the current project supports and which the capability model has no reason to remove.Acceptance tests cover Linux and macOS with
TMUX_TMPDIRset and unset. Two instances of this server on one uid sharing a name share a world, so one hot agent per socket; a second agent gets its own name. No tool takes a socket parameter; working on two sockets means two server entries in the client config, named apart,teardownnever enabled on the live one.Configuration is part of the ambient execution context, not of the namespace. A stock new server on a fresh socket still reads the user's tmux configuration, which is itself a command stream: hooks, plugins, status jobs, and
default-commandall ride in. So when this server is the one that starts the tmux server, on any non-inherit socket, it starts it with a shipped minimal configuration, making the agent workspace deterministic and hook-quiet by default.LIBTMUX_TMUX_CONFIGoverrides with a path or the valueuser. Configuration provenance is fixed when a tmux server first starts, not by how a later client selects the socket, soinheritmay reach a server started from user, custom, package-managed, or unknown configuration.LIBTMUX_TMUX_CONFIGtherefore governs only a server this invocation actually starts. When the pinned socket's server already exists, startup discloses it:server_state=existing,configuration_provenance=unknown. Startup must also define how concurrent creation is serialized per resolved socket path, and whether an existing server of unknown provenance is adopted or refused.Watching the agent is one command, and the server generates it rather than documenting a static string, because
-Lresolves a name underTMUX_TMPDIRand a desktop-launched server and an interactive shell can disagree about that:$ tmux -N -S /tmp/tmux-1000/libtmux-mcp attach-Nis load-bearing. Plainattachmay start an absent server, and a user who runs the watch command before the agent creates its first session would start the socket from their own configuration, leaving the package to find a server of unknown provenance.-Nrefuses instead, and is available across the supported tmux matrix.-Swith the resolved path sidestepsTMUX_TMPDIRdisagreement entirely. Startup output andtmux://capabilitiesboth carryresolved_socket_pathand a shell-quotedattach_command.Server-absence semantics are typed, not blurred: plural discovery (
list_sessions,search_panes) returns empty collections;get_server_inforeturnsrunning=falsewith the socket; singular targets (get_session_info,capture_pane,snapshot_pane) return a typed not-found error;wait_for_textfails immediately when its pane does not exist. An empty capture means the pane exists and has no text, never that there is no server.create_sessionstarts the server; by default tmux exits when no sessions remain, thoughexit-emptycan change that, at which point the operator broom istmux -L libtmux-mcp kill-server.The self-kill guards carry over with honest scope: they protect the pane, window, and session containing this server process, only when the process lives inside the pinned socket, and only against the direct teardown tools. A desktop client spawning the server outside tmux gets no guard, and on the dedicated default the guard is idle for a better reason: the pane it exists to protect sits on a server the structured tools cannot reach. The guard earns its keep on
inherit.Toolsets and defaults
Four toolsets follow mechanical function. They exist for inventory configuration, context reduction, documentation navigation, and model routing. They are never called permissions, scopes, or safety levels. They are unordered sets replacing an ordered ladder, and that is the mechanical win: the old tiers accumulated upward, so kill tools could never be enabled without the typing tools, while
LIBTMUX_TOOLSETS=inspect,teardownis now a legal surface.inspect. Read tmux state and terminal output. Process reach
none. Client input is IDs, names, bounded patterns, and validated variable identifiers.manage. Change tmux structure or presentation. Process reach
none. No client-supplied executable input of any kind.wait_for_channelandsignal_channelsit here rather than ininspectbecausetmux wait-forregisters channel state on the server, wherewait_for_textonly reads.execute. Start or drive pane processes. The product lives here.
teardown. Delete tmux objects or retained scrollback. Irreversible at the tmux level.
Default: all four enabled. The socket default is what makes this cheap: out of the box the agent's objects are separated from the user's ordinary tmux server, and an agent that creates scratch sessions should be able to remove them. Hiding
kill_sessionwhilerun_shell_commandis enabled would not be containment anyway; the typed tool with an exact target, a guard, and an audit record is strictly better than the shell workaround it cannot prevent. The one rule when sharing a socket, dropteardown, appears in the install statement, the shared-socket recipe, and the trust page. What the default socket does not claim: objects on it are shared by every process that connects, so a server left by a previous run, a session the user made by hand, or a second concurrent instance are all reachable. Exclusive ownership would need an advisory lease keyed to the resolved socket path plus an explicit adoption setting for a server of unknown provenance; until that ships, the honest sentence is separation from the user's world, not sole ownership of this one.Inventory
47 tools.
inspect (18):
list_sessions,list_windows,list_panes,get_server_info,get_session_info,get_window_info,get_pane_info,capture_pane,capture_since,snapshot_pane,search_panes,find_pane_by_position,wait_for_text,get_tmux_variables,show_option,show_environment,show_hooks,call_read_tools_batch.manage (16):
rename_session,rename_window,select_window,select_pane,select_layout,resize_window,resize_pane,move_window,swap_pane,set_pane_title,enter_copy_mode,exit_copy_mode,wait_for_channel,signal_channel,set_mouse_enabled,set_history_limit.execute (9):
create_session,create_window,split_window,respawn_pane,run_shell_command,send_keys,send_keys_batch,paste_text,set_synchronize_panes.set_synchronize_panessits inexecutedespite issuing an ordinary window option, because toolsets exist to aid client policy rather than to mirror the tmux command. It is an input amplifier: once enabled, one approvedsend_keysreaches every pane in the window, so a client that auto-allowsmanagewould widen a later prompt's real target set after the user read it. Its manifest row also carriesamplifies_future_input, andsend_keysreports the actual resolved target set in its result and audit record.teardown (4):
clear_pane_scrollback,kill_pane,kill_window,kill_session.Naming is part of the consent surface.
run_commandbecomesrun_shell_command, because in a tmux project a command could mean a tmux command, an MCP tool, or a shell command, and the name appears verbatim in permission rules, transcripts, prompts, and audit records.clear_panebecomesclear_pane_scrollback, naming exactly what is lost, and moves toteardown, which leavesmanageholding only non-deleting state changes.display_messagebecomesget_tmux_variables.show_hookfolds intoshow_hooksvia an optional name.Spawns carry no payloads, and that claim has one more condition than parameter removal.
create_session,create_window, andsplit_windowaccept no command string, no shell string, and no environment mapping; they start the pane's configured process and return IDs. Butstart_directorysurvives, and tmux runs the-cvalue throughformat_single(spawn.c,cmd-new-session.c), where a#(...)job executes through/bin/sh. Verified against tmux 3.7d:tmux new-session -d -c '#(touch FILE)'creates the file. So the commandless claim holds only once every format-bearing input is constructionally literalized — resolve and validate the path in Python and escape tmux format metacharacters, or set the tmux subprocess's owncwdand omit-c. Until that lands, these tools carry a caller-controlledtmux-formatsink and cannot advertiseconfigured-processreach ordestructive: false.run_shell_commandis the canonical authored interactive-shell command surface, called next — not the only client-authored execution route, sincesend_keys,paste_text, andrespawn_panealso carry authored input. The extra call buys a separate approval event, one command path to review, no spawn-time secrets in the process table, no environment-redaction special cases, and spawns whose honest annotation is additive.respawn_panekeeps its optional replacement command because process replacement is its entire purpose, so the one-surface claim is precisely one client-authored command surface plus that stated, annotated exception. The spawn-then-run sequence is not a new race: it is already this product's canonical documented flow, andrun_shell_command's completion protocol turns a shell that swallows early input into a visible timeout rather than silent corruption, with fresh-pane readiness a named implementation requirement. Exec-as-pane-process, where the command is the pane and no shell is involved, stays expressible by typingtmux new-windowwith a command throughrun_shell_command, on the surface labeled execution. That workaround has a stated cost: the audit record says a shell command ran, not that this exact pane process was spawned, and the new pane ID takes a second discovery step. If that cost proves too high in practice the answer is explicitspawn_*_processtools inexecutetaking a typedargvlist — not an optional command parameter back on the additive creation tools.Two typed setters carry mandatory description lines beyond their openers:
set_synchronize_panesstates that it duplicates subsequent pane input to every pane in the window, amplifying what onesend_keysreaches;set_history_limitcarries bounded integers and a note that retained-history behavior is version-tested before the tool claims to be loss-free.call_read_tools_batchsurvives because every inner call isreadOnly, so its own annotation is honest and it is a real round-trip win. It does not preserve per-inner client approval, and the document does not claim otherwise: a client must treat it as authority over every tool in its schema enum. That enum is the batch-eligibleinspectset — excluding itself, excluding the self-bounded and blocking tools, and excludingshow_environmentunless deliberately included — capped at sixteen operations and an aggregate response size, with one child audit event per operation and the worst-caseopenWorldof any eligible member. Excluding the wait tools is what makes the deadline question disappear; sharing one aggregate deadline across nested waits is implementable but it is a new control-loop feature with its own cancellation semantics, not carried-over behavior. The general batch does not survive, see below.Not carried over
kill_server. Redundant for the agent: killing its sessions one by one empties the server, and tmux by default exits with them. The socket-wide wipe belongs to the operator, as does wedged-server recovery and anyexit-emptyconfiguration. It was the one tool that turned a forgotten shared-socket line into losing an entire attached tmux, and a typed version with a schema makes that accident easier to reach, not harder.call_tools_batchand the mutating and destructive batches. A generic wrapper defeats the design's own consent layer: client policy keys on stable tool names, and a batch gives every inner operation the wrapper's name, so an always-confirm rule onkill_sessionnever fires inside an allowed batch, and one outer approval is all the protocol guarantees the client will render. Purpose-built composites —snapshot_pane,capture_since,run_shell_command,send_keys_batch— each do one kind of thing under one honest name; arbitrary composition is the client's job.set_optionandset_environment. A generic value can carry formats, commands, hooks, or escape sequences, and an allowlist is a second tmux policy database that must be argued forever. Typed setters with closed schemas ship where a workflow earns them, three at launch, and everything else goes throughrun_shell_command, where changing tmux configuration is advertised as what it is: execution. Per-command environment is theenv VAR=value commandprefix on that same surface.pipe_pane. The one tool whose command was not represented by a pane and ran outside the observable command-completion loop. Cutting it is what makes the no-host-side-pipelines sentence true.load_buffer,paste_buffer,show_buffer,delete_buffer. Agents type.paste_textmay use a server-namespaced buffer internally, cleaned up on success and ordinary failure.list_serversand every per-tool socket parameter.tmux://capabilities, and it reports the effective startup-filtered surface, not the unfiltered source manifest.Configuration
Configuration variables, each mirrored by a CLI flag.
LIBTMUX_SOCKET. Bare name,inherit, orpath:/abs/socket, per the semantics above. Defaultlibtmux-mcp.LIBTMUX_TMUX_CONFIG. Configuration for any tmux server this process itself starts, on every non-inherit socket: minimal by default, a path, oruser.LIBTMUX_TOOLSETS. Comma list. Defaultinspect,manage,execute,teardown. May be empty.LIBTMUX_TOOLS. Individual tools to enable regardless of toolset.LIBTMUX_EXCLUDE_TOOLS. Individual tools to disable. Beats every enable.LIBTMUX_SUPPRESS_HISTORYand the wait ceiling, unchanged.Selection: expand the toolsets, union the tools, subtract the exclusions. Unknown toolset or tool names fail startup with an error naming the valid set, because silent fallback is how a typo becomes a capability grant. The surface is resolved once at startup, is deterministic in order, and no runtime tool can reconfigure it: no MCP call can change the current process's effective registered tool inventory. An execute-capable agent can still edit configuration that a later restart would read.
LIBTMUX_READ_ONLYis either shipped as a strict floor that overridesLIBTMUX_TOOLS, or not reserved at all. It is not a duplicate ofLIBTMUX_TOOLSETS=inspect, becauseLIBTMUX_TOOLScan union an execute tool back into that surface, and a reserved name with undefined precedence is worse than no name.Client configuration recipes: shared socket, observer, narrow pipeline
Shared socket, direct deletion tools removed:
{ "mcpServers": { "tmux-live": { "command": "uvx", "args": ["libtmux-mcp"], "env": { "LIBTMUX_SOCKET": "inherit", "LIBTMUX_TOOLSETS": "inspect,manage,execute" } } } }Observer over your real sessions:
{ "mcpServers": { "tmux-live": { "command": "uvx", "args": ["libtmux-mcp"], "env": { "LIBTMUX_SOCKET": "inherit", "LIBTMUX_TOOLSETS": "inspect" } } } }Exact narrow surface for a pipeline:
{ "mcpServers": { "tmux-tests": { "command": "uvx", "args": ["libtmux-mcp"], "env": { "LIBTMUX_TOOLSETS": "", "LIBTMUX_TOOLS": "list_sessions,create_session,run_shell_command,capture_since" } } } }The default needs no block at all.
First contact
The dedicated socket is correct namespacing and, unannounced, looks like a broken install: the user watches their terminal and nothing happens there. Two treatments, both mandatory.
The quickstart leads with two equal cards, not one default and one buried recipe: the agent's own terminal (default, zero config, attach to watch) and drive the session you are in (
inherit,teardowndropped, execution still on and said so). Neither is the advanced option.The server instructions tell the agent to tell the user. One line in the budget: operating on tmux socket
libtmux-mcp; the user can watch withtmux -L libtmux-mcp attach; say so when starting work.Manifest
Every tool is one row in one checked-in table, and registration, filtering, docs, badges, the README inventory, and the capabilities resource all generate from it. A row carries the name, the title (emitted as the top-level tool title), toolset, tmux effect, process reach, output sensitivity, the four MCP behavioral hints as explicit booleans, an internal input-sinks column, the opening sentence key, a self-bounded flag, and the default-enabled flag. Self-bounded survives the batch cut because the fact has other consumers: the retry middleware reads it to decide what is safe to re-invoke, and the wait tools carry it for deadline accounting, so it lives in the manifest rather than dying with the feature that first motivated it.
destructiveHintis used strictly as the protocol defines it:falseasserts the tool performs only additive updates, andtrueis the cautious default. An earlier draft of this document setdestructive: falseacrossmanageon the grounds that inflating the hint onto renames and resizes teaches users to stop reading prompts. That is a prompt-profile argument overriding a protocol claim, which is the exact conflation this redesign exists to end, so it is withdrawn. Rename, resize, select, move, swap, replace-layout, replace-title, toggle-mouse, and replace-history-limit are not additive —rename_windowadditionally disables automatic renaming for that window — and all of them advertisedestructive: true. Only pure observation, additive creation with no replacement flag and no executable or format-bearing payload, and unique buffer staging keepfalse.That leaves
destructiveHinta coarse read/write split, which is the protocol's own resolution and not this project's to widen. The discrimination a client actually needs is carried by toolset, process reach, and the opening sentence:rename_windowismanage / change / reach none,run_shell_commandisexecute / change / pane-command. A client that understands only the standard hints behaves conservatively, and that is the correct outcome.The
openWorldHintquestion is decided rather than left open: terminal-content reads shipopenWorld: true. A pane can hold output from SSH, package managers, log streams, test fixtures, and remote agents, and the hint concerns both where a tool reaches and what its output carries back. Markingcapture_pane,capture_since,snapshot_pane,search_panes, andwait_for_textopen-world tells a client that returned text crossed a trust boundary. Structural metadata, options, hooks, environment, and direct teardown stay closed.The internal input-sinks column records where each client parameter lands: none, tmux lookup, tmux state value, pane input, shell command, process argv, python regex, or tmux-format. That last value is the one the
start_directoryfinding forced: tmux expands formats in argument positions whose names give no hint of it, so a sink table is the only durable form of this invariant and a schema field-name filter is not. Two further columns,amplifies_future_inputandmay_trigger_configured_hooks, record facts the three public properties cannot express — the first forset_synchronize_panes, the second because tmux runs after-hooks on nearly any command, so annotations describe the direct operation this server issues and never the ambient configuration around it. It is not exported; it exists so CI can assert sink-level facts. The three public properties additionally ship as namespaced custom metadata, reverse-DNS keys undercom.git-pull.libtmux-mcp/, which clients may ignore; nothing depends on them.Eleven invariants, enforced in CI as property tests over the manifest and the registered server
inspecttool:readOnly: true, reachnone, and no pane-input, shell-command, process-argv, process-environment, or caller-controlled tmux-format sink. Asserted against the sink table, never against schema field names. EveryreadOnlytool sits ininspect.host-commandreach; startup printshost_command_tools=0. No public schema containssocket,socket_name, orsocket_path.destructive: true,idempotent: false,openWorld: true. Every non-additive state update:destructive: true. Every additive creation:destructive: falseonly once every caller-controlled format and execution sink is eliminated. Everyteardowntool:destructive: true,openWorld: false. Terminal-content reads:openWorld: true. No tool advertising that it accepts no executable input may expose a caller-controlled tmux-format sink. Adestructive: falseanywhere requires a linked per-tool test asserting the operation is observational or additive; CI enforces the test's presence, review enforces its honesty.call_read_tools_batchexcludes itself, every self-bounded or blocking tool, and every tool whose output classes exceed its own advertised worst case; its enum is the batch-eligibleinspectset, its operation count and aggregate response size are capped, and it emits one child audit event per inner call.get_tmux_variablesrejects anything outside the variable-name grammar; search patterns carry size bounds and a bounded-time matching mechanism; temporary paste buffers are namespaced and cleaned up.tmux://capabilitiesreports the startup-filtered surface.MIGRATIONandCHANGES, which must name what they replace. Copy cannot drift back toward guarantees the implementation does not make.Controlled openers
The first sentence of every description is generated from process reach and tmux effect. It is the shell-reach vocabulary the protocol lacks, placed where models and permission prompts already read:
Routing guidance follows in the existing docstring voice, steering models toward the calls that do more per approval:
run_shell_commandfolds type, wait, and capture into one call,send_keys_batchfolds ordered input into one. One invocation, one typed result, one completion boundary — whether and how a client confirms it is the client's decision, not something the protocol guarantees.The inspect claim
The claim is about interpretation of client input, not about the absence of processes, because tmux is a programmable environment: configured hooks can fire on nearly any tmux command. The minimal default configuration keeps the dedicated workspace hook-quiet; on
inheritand user-configured sockets the caveat is load-bearing. Stated on the trust page:get_tmux_variablesis the constructional half: it takes a capped list of variable names matching^[A-Za-z][A-Za-z0-9_]*$and issues one tmux invocation per variable, so returned values can never be confused with a delimiter and the guarantee is provable rather than parsed. A user who genuinely needs raw format syntax runstmux display-message -pthroughrun_shell_command, where it is advertised as execution, which is where it belongs.search_panesnames its mechanism instead of gesturing at one: a bounded-time regex engine (theregexmodule'stimeoutor an RE2 binding, fixed at implementation) plus pattern-length caps. A length cap alone is not a time ceiling and is never claimed to be.Disclosure surfaces
Server instructions carry, inside the existing byte budget: the pinned socket and the tell-the-user attach line, the enabled toolsets, one sentence that execute tools run commands with this user's permissions, one sentence that tool filtering is not an OS sandbox and does not constrain other MCP tools, the routing preferences, and the caller-pane context when inside tmux, including the cross-socket case: when the agent's own pane lives on a different server than the pinned socket, the instructions say so and note that these tools cannot reach that pane.
Startup prints one record: version, socket and its form, whether the tmux server pre-existed and the configuration provenance, toolsets, tool count,
command_capable,host_command_tools=0, plus a warning line when the socket resolves to an inherited shared server.tmux://capabilitiesreturns the socket, provenance, and effective enabled surface machine-readably.Audit events carry tool, toolset, tmux effect, process reach, duration, outcome, client and request IDs, resolved target IDs, and a redacted argument summary; executable payloads appear as length plus a digest keyed with a per-process random secret, because an unkeyed
sha256prefix of a short command is a dictionary lookup away from plaintext, and calling that redaction would be the exact overclaim genre this design exists to remove; results are never copied in. The trust page scopes both honestly: audit redaction covers the audit record only, and history suppression is history hygiene, not secret transport.One trust-model page replaces any safety page: the install statement, the three properties, the socket and configuration scope, the inspect claim, the
destructiveHintdivision of labor, what toolset filtering does and does not bound, output sensitivity, guard scope, and OS and container guidance. Tool pages generate from the manifest and docstrings.What is not built
kill_server. Compositionally redundant, uniquely catastrophic.send_keysandrun_shell_command. That race is unwinnable, and a filter that sometimes wins teaches operators it always does.LIBTMUX_TOOLSETS=inspect.Carried over unchanged
run_shell_command's completion protocol,wait_for_textandwait_for_channel,capture_sincecursors,snapshot_pane, typed IDs and targeting, correctable error results, history suppression, the self-kill guards within their stated scope, digest-redacted audit records, tail-preserving output limits, and the instructions byte budget. The redesign is namespacing and advertisement; the control loop is the product and already correct.Adoption
The alpha banner licenses hard breaks, but the work phases, because one piece is a live defect and another lives in a second repository.
Phase zero ships on the current alpha immediately, independent of everything else, and it is a per-tool audit rather than a flip of shared constants.
ANNOTATIONS_SHELLadvertisesdestructiveHint: falseon seven tools that do not behave alike:send_keys,send_keys_batch,run_command,paste_text,pipe_pane, andpaste_bufferall deliver input to a program, whileload_buffershares the preset yet only stages content into a newly allocated buffer — the module's own docstring says the preset is for "commands about to be pasted into a shell", so the code already contradicts its stated rationale.ANNOTATIONS_MUTATINGclaims additive, idempotent behavior for setters that overwrite.split_windowis the worst row: it accepts ashellcommand and anenvironmentmapping while registered withANNOTATIONS_CREATE, advertisingdestructiveHint: falseandopenWorldHint: false. In the spec's vocabulary,destructive: falseis a positive claim of additive-only updates andtrueis the default being overridden, so all of this is a standing misstatement to every connected client and does not wait for the redesign.Phase zero also closes the format sink, because it is live in shipped code and not merely a flaw in this proposal:
start_directoryreaches tmux unvalidated oncreate_session,create_window,split_window, andrespawn_pane.display_messagerejects#(; these four have no equivalent guard. Terminal-content reads get theiropenWorldHintdecision in the same pass.Then, in order:
tmux-format, retry policy, batch eligibility, and hook activation — with only the CI-checkable invariants, and an enumeration of every consumer of the legacy tags before any tag changes. Its rows are written against current names and migrate in lockstep with the later phases.readonly, so it grows a configurable vocabulary, releases, and the docs pin bumps before the rename lands here.LIBTMUX_SAFETYdeleted with a startup error namingLIBTMUX_TOOLSETS. The default here is the behaviourally equivalentinspect,manage,execute— not the four-toolset default — because the socket still resolves to the user's ordinary tmux world at this point, and enablingteardownagainst it is precisely the accident the dedicated namespace exists to prevent.MIGRATIONcarrying an explicit old-to-new tool name map and docs redirect entries, because the stable-names argument is a promise about the future, not the past.name:/path:/inheritselectors, per-process pinning, configuration provenance and the minimal default configuration, no-start read probes, generated attach command, and adoption behaviour for an existing server. This phase has to precede the removal oflist_servers, everysocket_nameparameter, and the socket-bearing resource templates — those cannot land before the server knows how to pin one socket at startup. The process default may stayinheritfor one alpha.The socket default and the
teardowndefault flip together, last, in one release, after the two-card quickstart, the generated attach command, and the tell-the-user instruction line exist. The atomicity is the point:default socket: name:libtmux-mcpanddefault toolsets: inspect,manage,execute,teardownare only jointly defensible, since teardown-by-default is cheap exactly because the socket is the agent's own. Either alone is a regression. The quickstart treatments are the mitigation for the empty first look, and shipping the flip before them ships the confusion without the cure. That release's migration note leads with it: upgraded installs see an empty world onlibtmux-mcp, running a minimal configuration, until the operator attaches there or setsLIBTMUX_SOCKET=inherit.MIGRATIONrecords the rest of the mapping:readonlybecomesLIBTMUX_TOOLSETS=inspect,mutatingbecomesinspect,manage,execute,destructivebecomes the default, the old socket behavior becomesinherit, andkill_server,set_option,set_environment,pipe_pane, and the generic batches no longer exist.tony commentedon Aug 29, 2026
ADR 0001: Capability model
Intended to land as
docs/dev/adr/0001-capability-model.md. The full target-state design, including the inventory, manifest columns, CI invariants, and adoption phases, is the capability model comment on this issue. This record is the altitude above it: what we are committing to, and what we are refusing to promise.Status
Proposed. Supersedes the
LIBTMUX_SAFETYthree-tier model.Context
libtmux-mcp hands an agent a terminal. Everything downstream of that — which tmux objects it can touch, what a client should prompt on, what a reader should believe — depends on describing that capability accurately.
One ordered scale cannot do it. Running a shell command and deleting a tmux window are different powers, not different amounts of one power, and a ladder that ranks them forces every tool to be described by its position rather than its behaviour. The practical symptom is that a rung has to lie about one axis to speak about the other, and the honest statement a user needs — this can execute code as you — ends up implied by a tier name instead of stated in the tool's own description.
The second problem is that the words available are not ours alone. MCP defines
readOnlyHint,destructiveHint,idempotentHint, andopenWorldHintwith specific meanings and explicitly frames them as untrusted hints for client presentation, not authorization. A server that repurposes them as severity labels is not communicating with clients; it is corrupting a shared vocabulary.Decision
CM-1 — The tmux socket is the namespace boundary, and it is only that. A server process pins one socket for its lifetime. That scopes which tmux objects the structured tools can name, which is genuine accident isolation for the tool surface. It scopes nothing about the filesystem, processes, network, or other sockets, and the documentation never implies otherwise.
CM-2 — Capability is described by independent properties, not a rank. Process reach (can this start a process or deliver client-controlled input to one) and tmux effect (observe, change, delete) vary independently, and output classes describe what a result can carry back.
rename_windowandrun_shell_commandboth change tmux state; only one can run code. A model with two axes says that in one line, and a ladder cannot say it at all.CM-3 — Toolsets are unordered inventory sets, resolved once at startup.
inspect,manage,execute,teardown. They shape what this server advertises, for context reduction, model routing, and documentation navigation. Because they are sets rather than a cumulative scale,inspect,teardownis expressible; under the old ladder, deletion tools could not be enabled without the typing tools. Tag-based inventory filtering is also how the surrounding ecosystem already works — FastMCP's own config layer exposesinclude_tags/exclude_tagsover the same mechanism.CM-4 — Standard MCP annotations are used strictly as the protocol defines them.
destructiveHint: falseasserts additive-only updates andtrueis the cautious default, so replacement operations advertisetrueeven when that produces a coarser prompt profile than we would like. Nuance is carried by our own vocabulary — toolset, process reach, output classes, and a generated opening sentence — never by bending a shared hint. A client that understands only the standard hints behaves conservatively, which is the correct outcome.CM-5 — One checked-in manifest is the single source of truth, and CI asserts against sinks rather than names. Registration, filtering, generated docs, badges, the README inventory, and the capabilities resource all derive from one table. Invariants are asserted on where a parameter's value lands in the callee, not on what the parameter is called: tmux expands formats in argument positions whose names give no hint of it, so a field-name filter would pass a shell sink named
start_directory.CM-6 — There is one canonical surface for client-authored shell commands, and none for host commands.
run_shell_commandruns authored commands in a pane, where they are attachable, observable, and tied to a completion protocol. No public tool runs client-authored code outside a pane. This is an architecture and observability claim, not confinement.CM-7 — Disclosure is a product surface, held to the same standard as behaviour. The install statement, the generated opening sentences, the startup record, the trust-model page, and the audit record are all deliverables, and a repository lint rejects new affirmative sandbox or containment language while permitting negative boundary disclosures.
What this does not guarantee
Each of these is stated because a reader could otherwise reasonably infer it.
Not containment. Execute tools run commands with the user's full authority. A command running in a pane can reach any file, process, or network the user can, and can open any other tmux socket by hand. OS accounts, containers, and VMs are the isolation boundary; this server is not one. We do not build a sandbox because a server that cannot confine its own child processes cannot honestly claim to.
Toolset filtering is not authorization. Dropping
teardownremoves the direct deletion tools from the advertised inventory. It does not stop an enabled execute tool from typing the equivalent tmux command. We ship it as inventory configuration and accident reduction, and refuse to describe it as a permission system, because a bypass that is onesend_keysaway is not a boundary.Annotations describe the direct operation this server issues, never the ambient tmux around it. tmux is programmable: roughly half of its command entries declare
CMD_AFTERHOOK, and a hook canrun-shell. So a nominally read-only call can trigger a configured mutation on an inherited or user-configured server. The manifest records hook activation separately; the hints stay a statement about our own issued command, and on unknown configurations clients must not read them as a claim about ambient behaviour.The dedicated socket is separation, not exclusive ownership. Objects on it are reachable by every process of the same user that connects — a server left by a previous run, a session made by hand, a second concurrent instance. Exclusive ownership would need an advisory lease and an explicit adoption step for a server of unknown provenance. Until that exists we say separation from the user's ordinary tmux world, and never "only this agent's objects."
inspectmeans "does not interpret client input as executable", not "safe". Inspect tools can return credentials, command lines, environment values, and terminal output containing prompt-injection text. Terminal-content reads are therefore advertised open-world. Blanket auto-approval of the whole toolset is a client's decision to make with that stated, not something the name endorses.No payload inspection on typed input. We do not scan
send_keysorrun_shell_commandfor dangerous content. That race is unwinnable, and a filter that catches enough examples to look protective is worse than none, because it teaches operators to rely on it.Bounded matching is a mechanism we must supply, not something the language gives us. CPython's
rehas no execution timeout, so pattern-length caps alone are not a time ceiling. Search bounds mean a specific bounded-time engine, named at implementation, or the guarantee is not made.Redaction and history suppression are scoped. Audit redaction covers the audit record; it does not rewrite shell history, client transcripts, pane scrollback, process arguments, or OS observation surfaces. History suppression is best-effort hygiene, not secret transport.
Aggregate calls do not preserve per-inner client approval. The single read batch that survives aggregates authority under its own name, so a client policy keyed on an inner tool's name will not fire. That is stated in the tool's own description rather than papered over, and it is why the mutating and destructive batches do not survive at all.
Names are not yet stable. The model depends on literal tool names as client policy hooks, which makes the alpha's renames a one-time break rather than a free change.
MIGRATIONcarries the explicit old-to-new map; the stability promise begins after this lands, not before.Self-kill guards protect one process against the direct teardown tools. They cover the pane, window, and session containing this server, only when it lives on the pinned socket, and only against those typed tools — not against an equivalent command typed through an execute tool, and not at all for a server launched outside tmux.
Consequences
The honest annotation policy costs prompt granularity: many
managetools becomedestructive: true, and clients that gate on that hint will prompt more. That is accepted. The signal a client needs to distinguish a rename from a shell command moves into toolset, reach, and the opening sentence, which are ours to define and are visible in the standard description field rather than in metadata a client may ignore.Removing per-call socket arguments means two tmux servers require two configured MCP entries. Deriving everything from one manifest means adding a tool is adding a row plus a test, and a tool whose claims drift from its behaviour fails CI rather than shipping.
The changes span two repositories: the documentation extension hardcodes the old tier vocabulary and silently renders unknown tags as
readonly, so it has to grow a configurable vocabulary and release before any tag rename lands here.Because
exit-emptydefaults on (options-table.c), an agent that removes its own sessions empties its server without a socket-wide kill tool, which is why none is offered.The result is a server that is useful by default, explicit that it can execute arbitrary code, precise about the little the socket actually scopes, and structured so that a future tool cannot quietly acquire authority the documentation does not admit to.