From c2bfd58647c6050ef1ace9453b88f0ab75c84544 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:47:41 -0500 Subject: [PATCH 01/11] docs(adr): Record the capability model why: The capability refactor needs a durable record of the boundary it defines and the guarantees it declines to make. what: - Add a numbered architecture decision collection - Preserve issue #127's proposed capability model and provenance - Link the collection from the contributor project page - Enforce the navigation and source-decision contract --- docs/dev/adr/0001-capability-model.md | 189 ++++++++++++++++++++++++++ docs/dev/adr/index.md | 12 ++ docs/project/index.md | 7 + tests/docs/test_topic_contracts.py | 33 +++++ 4 files changed, 241 insertions(+) create mode 100644 docs/dev/adr/0001-capability-model.md create mode 100644 docs/dev/adr/index.md diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md new file mode 100644 index 00000000..93cc2352 --- /dev/null +++ b/docs/dev/adr/0001-capability-model.md @@ -0,0 +1,189 @@ +(adr-capability-model)= + +# ADR 0001: Capability model + +This record was proposed in +[issue #127's source comment](https://github.com/tmux-python/libtmux-mcp/issues/127#issuecomment-5463431049). +The +[full target-state capability model](https://github.com/tmux-python/libtmux-mcp/issues/127#issuecomment-5463166342) +contains the inventory, manifest columns, CI invariants, and adoption phases. +This record stays above that implementation design: it states what the project +commits to and what it refuses to promise. + +## Status + +Proposed. Supersedes the former three-level capability 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`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.29.1/src/mcp/types.py#L1247-L1294) +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](https://gofastmcp.com)'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`](https://github.com/tmux/tmux/blob/3.7c/cmd-queue.c), 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`](https://github.com/python/cpython/blob/v3.14.0/Lib/re/__init__.py) 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`](https://github.com/tmux/tmux/blob/3.7c/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. diff --git a/docs/dev/adr/index.md b/docs/dev/adr/index.md new file mode 100644 index 00000000..e02b0bc4 --- /dev/null +++ b/docs/dev/adr/index.md @@ -0,0 +1,12 @@ +(architecture-decisions)= + +# Architecture decisions + +Architecture decision records document product-wide choices whose trade-offs and +consequences outlive one implementation. + +```{toctree} +:maxdepth: 1 + +0001-capability-model +``` diff --git a/docs/project/index.md b/docs/project/index.md index 2b60711f..bd210e45 100644 --- a/docs/project/index.md +++ b/docs/project/index.md @@ -25,6 +25,12 @@ Ruff, mypy, NumPy docstrings, import conventions. Release checklist and version policy. ::: +:::{grid-item-card} Architecture decisions +:link: ../dev/adr/index +:link-type: doc +Long-lived design choices, their trade-offs, and their consequences. +::: + :::: ```{toctree} @@ -33,4 +39,5 @@ Release checklist and version policy. contributing code-style releasing +../dev/adr/index ``` diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 955d3013..8fa8751d 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -334,6 +334,39 @@ def test_trust_docs_state_ambient_tmux_execution( assert "{ref}`trust`" in resources +def test_project_navigation_publishes_architecture_decisions( + docs_dir: pathlib.Path, +) -> None: + """The contributor landing page exposes the numbered ADR collection.""" + project = (docs_dir / "project" / "index.md").read_text(encoding="utf-8") + index = (docs_dir / "dev" / "adr" / "index.md").read_text(encoding="utf-8") + + assert ":::{grid-item-card} Architecture decisions" in project + assert ":link: ../dev/adr/index" in project + assert "\n../dev/adr/index\n" in project + assert "(architecture-decisions)=" in index + assert "# Architecture decisions" in index + assert "\n0001-capability-model\n" in index + + +def test_capability_adr_preserves_the_source_decision( + docs_dir: pathlib.Path, +) -> None: + """ADR 0001 retains the proposed capability model and its provenance.""" + text = (docs_dir / "dev" / "adr" / "0001-capability-model.md").read_text( + encoding="utf-8" + ) + + assert "(adr-capability-model)=" in text + assert "# ADR 0001: Capability model" in text + assert "## Status\n\nProposed." in text + assert "issues/127#issuecomment-5463431049" in text + for number in range(1, 8): + assert f"**CM-{number} —" in text + assert "## What this does not guarantee" in text + assert "## Consequences" in text + + def test_trust_toolset_labels_are_badged_and_copyable( docs_dir: pathlib.Path, ) -> None: From 737beb1e9a9f4fe0eedbff309f25b2bcd9c6cad9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:10:16 -0500 Subject: [PATCH 02/11] docs(adr): Scope MCP hints to whole calls why: Direct-only annotations contradict MCP semantics when tmux aliases and hooks can change the whole call. what: - Define standard hints over the complete call - Keep direct-operation detail in project metadata --- docs/dev/adr/0001-capability-model.md | 40 ++++++++++++--------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index 93cc2352..fb074026 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -58,13 +58,13 @@ the typing tools. Tag-based inventory filtering is also how the surrounding ecosystem already works — [FastMCP](https://gofastmcp.com)'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-4 — Standard MCP annotations describe the whole tool call.** MCP defines +[`readOnlyHint: true`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.29.1/src/mcp/types.py#L1262-L1266) +as a claim that the tool does not modify its environment, and +[FastMCP passes annotations through](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/tools/base.py#L234-L242). +Tmux operations therefore use conservative hints when aliases or hooks make the +whole call unknowable. Toolset, process reach, and output classes describe the +direct operation without redefining the protocol. **CM-5 — One checked-in manifest is the single source of truth, and CI asserts against sinks rather than names.** Registration, filtering, generated docs, @@ -103,15 +103,13 @@ 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`](https://github.com/tmux/tmux/blob/3.7c/cmd-queue.c), 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. +**Annotations include ambient tmux behaviour.** tmux +[expands command aliases](https://github.com/tmux/tmux/blob/3.7c/cmd-parse.y#L776-L794) +before dispatch and +[runs after-hooks](https://github.com/tmux/tmux/blob/3.7c/cmd-queue.c#L649-L663) +after it. A nominally observational call can therefore execute or mutate. +Project metadata records the intended direct operation; standard hints make no +narrower claim. **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 @@ -162,12 +160,10 @@ 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. +The conservative annotation policy costs prompt granularity: tmux operations +decline every positive safety claim, so clients may prompt more. Direct-operation +distinctions move into toolset, reach, and the opening sentence, which are ours +to define. 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 From dffa00d296f91bc63dd8e474586a6ac9205ec427 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:10:51 -0500 Subject: [PATCH 03/11] docs(adr): Narrow the host-command claim why: Pane input can ask tmux to start host-side jobs, so only the direct MCP schema boundary is enforceable. what: - Define the prohibited direct host-command surface - Cite run-shell and status-job escape paths --- docs/dev/adr/0001-capability-model.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index fb074026..e6d5384b 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -74,11 +74,15 @@ 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-6 — Client-authored host commands have no direct MCP surface.** +`run_shell_command` sends authored commands to a pane, where they are attachable, +observable, and tied to a completion protocol. No public schema accepts a host +command, and this server never hands caller text directly to a host-side shell. +A pane command can still invoke tmux's +[`run-shell`](https://github.com/tmux/tmux/blob/3.7c/cmd-run-shell.c#L201-L210) +or install a +[`#()` status job](https://github.com/tmux/tmux/blob/3.7c/format.c#L416-L422), +so this is a direct-surface claim, not transitive confinement. **CM-7 — Disclosure is a product surface, held to the same standard as behaviour.** The install statement, the generated opening sentences, the startup From 6e63be138641a4d85dd8974a40a68f3b6df4d972 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:11:26 -0500 Subject: [PATCH 04/11] docs(adr): State toolset enforcement why: FastMCP visibility rejects disabled calls as well as hiding them, while pane authority remains unchanged. what: - Describe listing and invocation enforcement - Separate MCP surface control from confinement --- docs/dev/adr/0001-capability-model.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index e6d5384b..e1bf0978 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -50,13 +50,17 @@ classes describe what a result can carry back. `rename_window` and 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. +`inspect`, `manage`, `execute`, `teardown`. They determine what this server lists +and accepts, 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](https://gofastmcp.com)'s own config layer -exposes `include_tags` / `exclude_tags` over the same mechanism. +the typing tools. FastMCP maps +[`include_tags` and `exclude_tags`](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/mcp_config.py#L139-L146) +to visibility and filters both +[`tools/list`](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/server/server.py#L651-L678) +and +[`tools/call`](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/server/server.py#L1281-L1288) +through it. **CM-4 — Standard MCP annotations describe the whole tool call.** MCP defines [`readOnlyHint: true`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.29.1/src/mcp/types.py#L1262-L1266) @@ -101,11 +105,11 @@ 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. +**Toolset filtering enforces the MCP surface, not authority.** Dropping +`teardown` removes direct deletion tools from listing and invocation. It does not +stop an enabled execute tool from typing the equivalent tmux command. Toolsets +reduce accidents; they do not confine what a pane can do, because a bypass is one +`send_keys` away. **Annotations include ambient tmux behaviour.** tmux [expands command aliases](https://github.com/tmux/tmux/blob/3.7c/cmd-parse.y#L776-L794) From 75403cd31ff2aa789d972e779ed0f2eeec232328 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:13:25 -0500 Subject: [PATCH 05/11] docs(adr): Mark the remaining target state why: Proposed decisions were phrased as shipped behavior and retained a completed documentation prerequisite. what: - Separate released toolsets from remaining target work - Pin current-surface and gp-sphinx evidence to release tags --- docs/dev/adr/0001-capability-model.md | 30 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index e1bf0978..f5b29397 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -12,7 +12,18 @@ commits to and what it refuses to promise. ## Status -Proposed. Supersedes the former three-level capability model. +Proposed. Supersedes the former three-level capability model. The +[four toolsets](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/_utils.py#L385-L395) +already ship. The manifest, pinned socket lifecycle, target names and removals, +and generated disclosure surfaces remain proposed. Version 0.1.0a21 still +exposes +[`run_command` and per-call socket selection](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/pane_tools/io.py#L327-L338), +generic +[`set_option`](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/option_tools.py#L103-L119) +and +[`set_environment`](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/env_tools.py#L60-L70), +and +[`pipe_pane`](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/pane_tools/pipe.py#L32-L41). ## Context @@ -155,10 +166,10 @@ 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. +**Target 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` records each rename when it lands; the stability +promise begins after the target surface 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 @@ -178,16 +189,17 @@ 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. +The documentation extension and this server share the toolset vocabulary. +gp-sphinx +[accepts configured axes](https://github.com/git-pull/gp-sphinx/blob/v0.1.0a38/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py#L149-L158), +so releases keep those terms aligned. Because `exit-empty` defaults on ([`options-table.c`](https://github.com/tmux/tmux/blob/3.7c/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 +The target 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. From b131053e18a21b014e4b9b2ec139ec236e61c6b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:21:37 -0500 Subject: [PATCH 06/11] docs(adr): Pin remaining source lines why: Durable code citations need tagged revisions and exact line anchors. what: - Anchor the CPython regex signature - Anchor tmux's exit-empty default --- docs/dev/adr/0001-capability-model.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index f5b29397..2485f847 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -150,7 +150,8 @@ teaches operators to rely on it. **Bounded matching is a mechanism we must supply, not something the language gives us.** CPython's -[`re`](https://github.com/python/cpython/blob/v3.14.0/Lib/re/__init__.py) has no +[`re.search`](https://github.com/python/cpython/blob/v3.14.0/Lib/re/__init__.py#L174-L177) +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. @@ -195,7 +196,7 @@ gp-sphinx so releases keep those terms aligned. Because `exit-empty` defaults on -([`options-table.c`](https://github.com/tmux/tmux/blob/3.7c/options-table.c)), +([`options-table.c`](https://github.com/tmux/tmux/blob/3.7c/options-table.c#L375-L380)), an agent that removes its own sessions empties its server without a socket-wide kill tool, which is why none is offered. From c1d27b9a8e2152f7f3e2dfe79ebe2a8a21bf0027 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:53:27 -0500 Subject: [PATCH 07/11] docs(adr): Keep capability contract portable why: An ADR defines the model every tmux MCP implements; release state and language-specific mechanics belong elsewhere. what: - Keep CM identifiers attached to plain-English rules - Remove Python implementation status and tooling details - Phrase guarantees and consequences across implementations --- docs/dev/adr/0001-capability-model.md | 112 ++++++++++---------------- 1 file changed, 44 insertions(+), 68 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index 2485f847..123fca85 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -7,28 +7,18 @@ This record was proposed in The [full target-state capability model](https://github.com/tmux-python/libtmux-mcp/issues/127#issuecomment-5463166342) contains the inventory, manifest columns, CI invariants, and adoption phases. -This record stays above that implementation design: it states what the project -commits to and what it refuses to promise. +This record defines the shared contract for tmux MCP implementations. It stays +above implementation design: what every implementation commits to and refuses +to promise. ## Status -Proposed. Supersedes the former three-level capability model. The -[four toolsets](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/_utils.py#L385-L395) -already ship. The manifest, pinned socket lifecycle, target names and removals, -and generated disclosure surfaces remain proposed. Version 0.1.0a21 still -exposes -[`run_command` and per-call socket selection](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/pane_tools/io.py#L327-L338), -generic -[`set_option`](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/option_tools.py#L103-L119) -and -[`set_environment`](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/env_tools.py#L60-L70), -and -[`pipe_pane`](https://github.com/tmux-python/libtmux-mcp/blob/v0.1.0a21/src/libtmux_mcp/tools/pane_tools/pipe.py#L32-L41). +Proposed. Supersedes the former three-level capability 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 +A tmux 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 @@ -47,20 +37,20 @@ 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 +**CM-1 — A tmux socket is a namespace boundary, not a security boundary.** 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.** +**CM-2 — Describe capability with independent facts, 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.** +**CM-3 — Resolve one explicit tool surface at startup.** `inspect`, `manage`, `execute`, `teardown`. They determine what this server lists and accepts, for context reduction, model routing, and documentation navigation. Because they are sets rather than a cumulative scale, `inspect,teardown` is @@ -73,37 +63,34 @@ and [`tools/call`](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/server/server.py#L1281-L1288) through it. -**CM-4 — Standard MCP annotations describe the whole tool call.** MCP defines +**CM-4 — Use standard MCP annotations for the whole tool call.** MCP defines [`readOnlyHint: true`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.29.1/src/mcp/types.py#L1262-L1266) -as a claim that the tool does not modify its environment, and -[FastMCP passes annotations through](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/tools/base.py#L234-L242). -Tmux operations therefore use conservative hints when aliases or hooks make the -whole call unknowable. Toolset, process reach, and output classes describe the -direct operation without redefining the protocol. - -**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 — Client-authored host commands have no direct MCP surface.** +as a claim that the tool does not modify its environment. Tmux operations +therefore use conservative hints when aliases or hooks make the whole call +unknowable. Toolset, process reach, and output classes describe the direct +operation without redefining the protocol. + +**CM-5 — Generate public claims from one checked-in manifest.** Registration, +filtering, generated docs, badges, the README inventory, and the capabilities +resource all derive from one table. CI asserts where a parameter's value lands +in the callee, not 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 — Do not expose host-command execution as a public tool.** `run_shell_command` sends authored commands to a pane, where they are attachable, observable, and tied to a completion protocol. No public schema accepts a host -command, and this server never hands caller text directly to a host-side shell. +command, and no implementation hands caller text directly to a host-side shell. A pane command can still invoke tmux's [`run-shell`](https://github.com/tmux/tmux/blob/3.7c/cmd-run-shell.c#L201-L210) or install a [`#()` status job](https://github.com/tmux/tmux/blob/3.7c/format.c#L416-L422), so this is a direct-surface claim, not transitive 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. +**CM-7 — Treat disclosure as product behaviour.** The install statement, +generated opening sentences, startup record, trust-model page, and audit record +are all deliverables. Repository lint rejects new affirmative sandbox or +containment language while permitting negative boundary disclosures. ## What this does not guarantee @@ -112,9 +99,8 @@ 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. +VMs are the isolation boundary; a tmux MCP is not one. Implementations do not +build a sandbox they cannot enforce. **Toolset filtering enforces the MCP surface, not authority.** Dropping `teardown` removes direct deletion tools from listing and invocation. It does not @@ -127,7 +113,7 @@ reduce accidents; they do not confine what a pane can do, because a bypass is on before dispatch and [runs after-hooks](https://github.com/tmux/tmux/blob/3.7c/cmd-queue.c#L649-L663) after it. A nominally observational call can therefore execute or mutate. -Project metadata records the intended direct operation; standard hints make no +Manifest metadata records the intended direct operation; standard hints make no narrower claim. **The dedicated socket is separation, not exclusive ownership.** Objects on it @@ -143,18 +129,14 @@ 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. +**No payload inspection on typed input.** Implementations 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.search`](https://github.com/python/cpython/blob/v3.14.0/Lib/re/__init__.py#L174-L177) -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. +**Bounded matching requires a bounded mechanism.** Input-size caps alone do not +bound execution time. Each implementation names and enforces a bounded-time +matching mechanism, or makes no time-bound claim. **Redaction and history suppression are scoped.** Audit redaction covers the audit record; it does not rewrite shell history, client transcripts, pane @@ -167,10 +149,9 @@ 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. -**Target 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` records each rename when it lands; the stability -promise begins after the target surface lands, not before. +**Tool names are client policy hooks.** Implementations change accepted tool +names only through an explicit migration. Renaming a public tool is a consent +surface change, not an internal refactor. **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 @@ -190,17 +171,12 @@ 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 documentation extension and this server share the toolset vocabulary. -gp-sphinx -[accepts configured axes](https://github.com/git-pull/gp-sphinx/blob/v0.1.0a38/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py#L149-L158), -so releases keep those terms aligned. - Because `exit-empty` defaults on ([`options-table.c`](https://github.com/tmux/tmux/blob/3.7c/options-table.c#L375-L380)), an agent that removes its own sessions empties its server without a socket-wide kill tool, which is why none is offered. -The target 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 +The result is a shared model for tmux MCPs that are useful by default, explicit +that they can execute arbitrary code, precise about the little a socket scopes, +and structured so that a future tool cannot quietly acquire authority its documentation does not admit to. From 08ae9fcb7cb3379922c49e0c92fc93df747c3c29 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:53:53 -0500 Subject: [PATCH 08/11] docs(adr): Qualify direct process reach why: Ambient tmux configuration can add execution without changing the capability of the requested operation. what: - Scope process reach and tmux effect to the direct operation - State ambient execution in whole-call terms --- docs/dev/adr/0001-capability-model.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index 123fca85..51b1d704 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -44,11 +44,12 @@ surface. It scopes nothing about the filesystem, processes, network, or other sockets, and the documentation never implies otherwise. **CM-2 — Describe capability with independent facts, 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. +Direct process reach records whether the requested operation starts a process +or delivers client-controlled input to one. Direct tmux effect records whether +that operation observes, changes, or deletes state; output classes describe what +the result can carry back. `rename_window` changes a name, while +`run_shell_command` accepts a client-authored pane command. Whole-call MCP +annotations account separately for execution added by ambient aliases or hooks. **CM-3 — Resolve one explicit tool surface at startup.** `inspect`, `manage`, `execute`, `teardown`. They determine what this server lists From 817919e5cadb841d8becf25042b40c300eec05e1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:54:25 -0500 Subject: [PATCH 09/11] docs(adr): Define the effective tool surface why: Toolsets alone do not determine which tools a tmux MCP advertises and accepts. what: - Define named inclusion and exclusion precedence - Apply one frozen surface to discovery and invocation - Keep filtering distinct from pane authority --- docs/dev/adr/0001-capability-model.md | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index 51b1d704..66f837e4 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -52,17 +52,12 @@ the result can carry back. `rename_window` changes a name, while annotations account separately for execution added by ambient aliases or hooks. **CM-3 — Resolve one explicit tool surface at startup.** -`inspect`, `manage`, `execute`, `teardown`. They determine what this server lists -and accepts, 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. FastMCP maps -[`include_tags` and `exclude_tags`](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/mcp_config.py#L139-L146) -to visibility and filters both -[`tools/list`](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/server/server.py#L651-L678) -and -[`tools/call`](https://github.com/jlowin/fastmcp/blob/v3.4.7/fastmcp_slim/fastmcp/server/server.py#L1281-L1288) -through it. +The unordered toolsets are `inspect`, `manage`, `execute`, and `teardown`. At +startup, expand the selected toolsets, add named inclusions, then remove named +exclusions; exclusion wins. Reject unknown names and freeze the result for the +server's lifetime. A tool outside that surface is neither advertised nor +callable. This supports context reduction, model routing, and documentation +navigation while keeping combinations such as `inspect,teardown` expressible. **CM-4 — Use standard MCP annotations for the whole tool call.** MCP defines [`readOnlyHint: true`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.29.1/src/mcp/types.py#L1262-L1266) @@ -103,11 +98,11 @@ can, and can open any other tmux socket by hand. OS accounts, containers, and VMs are the isolation boundary; a tmux MCP is not one. Implementations do not build a sandbox they cannot enforce. -**Toolset filtering enforces the MCP surface, not authority.** Dropping -`teardown` removes direct deletion tools from listing and invocation. It does not -stop an enabled execute tool from typing the equivalent tmux command. Toolsets -reduce accidents; they do not confine what a pane can do, because a bypass is one -`send_keys` away. +**The selected tool surface limits MCP calls, not pane authority.** A surface +without teardown tools removes direct deletion calls. It does not stop an +enabled execute tool from typing the equivalent tmux command. Surface reduction +limits accidents; it does not confine what a pane can do, because a bypass is +one `send_keys` away. **Annotations include ambient tmux behaviour.** tmux [expands command aliases](https://github.com/tmux/tmux/blob/3.7c/cmd-parse.y#L776-L794) From df85c83c9a9023f6e3008c356b4c3b2cd5138c6f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:54:58 -0500 Subject: [PATCH 10/11] docs(adr): Qualify shared-socket shutdown why: Removing one caller's sessions does not empty a socket that other clients also use. what: - State the complete exit-empty condition - Keep socket-wide termination with the operator --- docs/dev/adr/0001-capability-model.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index 66f837e4..cc8e3385 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -169,8 +169,12 @@ rather than shipping. Because `exit-empty` defaults on ([`options-table.c`](https://github.com/tmux/tmux/blob/3.7c/options-table.c#L375-L380)), -an agent that removes its own sessions empties its server without a socket-wide -kill tool, which is why none is offered. +tmux becomes eligible to exit only after every session is removed; attached +clients can delay exit further +([`server.c`](https://github.com/tmux/tmux/blob/3.7c/server.c#L281-L292)). +Removing one MCP instance's sessions therefore does not stop a shared server +while another session remains. Socket-wide termination stays an operator action +because it may destroy sessions the caller does not own. The result is a shared model for tmux MCPs that are useful by default, explicit that they can execute arbitrary code, precise about the little a socket scopes, From 2af0b0cfc697b3c53efaaf79f646001afd0dcaa9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 15:10:21 -0500 Subject: [PATCH 11/11] docs(adr): Standardize the capability contract why: The decision must constrain every tmux MCP implementation without becoming a Python rollout report or requiring readers to decode internal labels. what: - Define scope, terminology, conformance, and BCP 14 requirements - Separate normative rules from rationale and implementation guidance - Record consequences, rejected alternatives, security limits, and reconsideration criteria - Keep CM identifiers attached to complete plain-English propositions --- docs/dev/adr/0001-capability-model.md | 530 ++++++++++++++++++-------- tests/docs/test_topic_contracts.py | 18 +- 2 files changed, 376 insertions(+), 172 deletions(-) diff --git a/docs/dev/adr/0001-capability-model.md b/docs/dev/adr/0001-capability-model.md index cc8e3385..9c5d9215 100644 --- a/docs/dev/adr/0001-capability-model.md +++ b/docs/dev/adr/0001-capability-model.md @@ -1,182 +1,376 @@ (adr-capability-model)= -# ADR 0001: Capability model +# ADR 0001: Capability boundaries for tmux MCP implementations -This record was proposed in -[issue #127's source comment](https://github.com/tmux-python/libtmux-mcp/issues/127#issuecomment-5463431049). -The -[full target-state capability model](https://github.com/tmux-python/libtmux-mcp/issues/127#issuecomment-5463166342) -contains the inventory, manifest columns, CI invariants, and adoption phases. -This record defines the shared contract for tmux MCP implementations. It stays -above implementation design: what every implementation commits to and refuses -to promise. +## Abstract + +This record defines the shared capability contract for tmux Model Context +Protocol (MCP) implementations. It specifies how implementations describe +available operations, how a tmux socket limits the structured tool namespace, +and what MCP annotations do and do not express. It does not prescribe an +implementation language, internal class design, or rollout sequence. ## Status Proposed. Supersedes the former three-level capability model. -## Context - -A tmux 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. +## Context and problem -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. +A tmux MCP gives an agent a terminal. The implementation must describe that +authority without confusing tmux object selection, process execution, client +consent, and operating-system confinement. -The second problem is that the words available are not ours alone. MCP defines +One ordered safety scale cannot describe those independent concerns. Running a +shell command and deleting a tmux window are different capabilities, not +different amounts of one capability. MCP also defines [`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.29.1/src/mcp/types.py#L1247-L1294) -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 — A tmux socket is a namespace boundary, not a security boundary.** 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 — Describe capability with independent facts, not a rank.** -Direct process reach records whether the requested operation starts a process -or delivers client-controlled input to one. Direct tmux effect records whether -that operation observes, changes, or deletes state; output classes describe what -the result can carry back. `rename_window` changes a name, while -`run_shell_command` accepts a client-authored pane command. Whole-call MCP -annotations account separately for execution added by ambient aliases or hooks. - -**CM-3 — Resolve one explicit tool surface at startup.** -The unordered toolsets are `inspect`, `manage`, `execute`, and `teardown`. At -startup, expand the selected toolsets, add named inclusions, then remove named -exclusions; exclusion wins. Reject unknown names and freeze the result for the -server's lifetime. A tool outside that surface is neither advertised nor -callable. This supports context reduction, model routing, and documentation -navigation while keeping combinations such as `inspect,teardown` expressible. - -**CM-4 — Use standard MCP annotations for the whole tool call.** MCP defines -[`readOnlyHint: true`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.29.1/src/mcp/types.py#L1262-L1266) -as a claim that the tool does not modify its environment. Tmux operations -therefore use conservative hints when aliases or hooks make the whole call -unknowable. Toolset, process reach, and output classes describe the direct -operation without redefining the protocol. - -**CM-5 — Generate public claims from one checked-in manifest.** Registration, -filtering, generated docs, badges, the README inventory, and the capabilities -resource all derive from one table. CI asserts where a parameter's value lands -in the callee, not 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 — Do not expose host-command execution as a public tool.** -`run_shell_command` sends authored commands to a pane, where they are attachable, -observable, and tied to a completion protocol. No public schema accepts a host -command, and no implementation hands caller text directly to a host-side shell. -A pane command can still invoke tmux's -[`run-shell`](https://github.com/tmux/tmux/blob/3.7c/cmd-run-shell.c#L201-L210) -or install a -[`#()` status job](https://github.com/tmux/tmux/blob/3.7c/format.c#L416-L422), -so this is a direct-surface claim, not transitive confinement. - -**CM-7 — Treat disclosure as product behaviour.** The install statement, -generated opening sentences, startup record, trust-model page, and audit record -are all deliverables. 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; a tmux MCP is not one. Implementations do not -build a sandbox they cannot enforce. - -**The selected tool surface limits MCP calls, not pane authority.** A surface -without teardown tools removes direct deletion calls. It does not stop an -enabled execute tool from typing the equivalent tmux command. Surface reduction -limits accidents; it does not confine what a pane can do, because a bypass is -one `send_keys` away. - -**Annotations include ambient tmux behaviour.** tmux -[expands command aliases](https://github.com/tmux/tmux/blob/3.7c/cmd-parse.y#L776-L794) -before dispatch and -[runs after-hooks](https://github.com/tmux/tmux/blob/3.7c/cmd-queue.c#L649-L663) -after it. A nominally observational call can therefore execute or mutate. -Manifest metadata records the intended direct operation; standard hints make no -narrower claim. - -**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.** Implementations 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 requires a bounded mechanism.** Input-size caps alone do not -bound execution time. Each implementation names and enforces a bounded-time -matching mechanism, or makes no time-bound claim. - -**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. - -**Tool names are client policy hooks.** Implementations change accepted tool -names only through an explicit migration. Renaming a public tool is a consent -surface change, not an internal refactor. - -**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. +for client presentation. Reusing those annotations as severity labels would +change their protocol meaning. + +## Scope and non-goals + +This record applies to MCP servers that expose structured tmux operations. It +defines their shared capability vocabulary, tool-surface behavior, annotation +semantics, execution boundary, and disclosure obligations. + +This record does not define operating-system isolation, decide which shell +commands an operator permits, or make the model an enforcement boundary. It +does not track implementation progress or prescribe a programming language, +framework, storage format, or build system. + +## Terminology + +**tmux MCP implementation** +: An MCP server implementation that exposes structured tools for tmux. + +**server process** +: One running instance of a tmux MCP implementation. + +**structured tool** +: A typed MCP operation that targets tmux or a program running in a tmux pane. + +**selected socket** +: The single tmux server socket chosen by a server process at startup. + +**direct operation** +: The operation a structured tool requests, excluding behavior already + configured in tmux, such as command aliases and hooks. + +**workload process** +: A pane or host process whose behavior can be influenced by caller input. A + control-plane process used only to issue a tmux request is not a workload + process. + +**whole-call MCP annotation** +: A standard MCP annotation describing the observable tool call as a whole, + including configured tmux behavior activated by the direct operation. + +**structured tool surface** +: The fixed set of tools a server process advertises and accepts. + +**host command** +: Client-authored executable input handed directly to a process outside a tmux + pane. + +**minimal tmux configuration** +: Configuration supplied by the implementation that loads no user configuration, + plugin, hook, or status job. + +## Conformance + +In this record, uppercase **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and +**MAY** use the meanings defined by +[BCP 14](https://datatracker.ietf.org/doc/html/rfc8174). Lowercase forms have +their ordinary English meanings. + +A **conforming tmux MCP implementation** satisfies every applicable **MUST** and +**MUST NOT** requirement in this record. The Terminology, Conformance, +Architectural decisions, and Security and reliability considerations sections +are normative. Sections and paragraphs labeled informative do not define +conformance requirements. + +## Architectural decisions + +### CM-1: A tmux socket limits addressable tmux objects; it is not a security boundary + +A server process **MUST** select exactly one tmux socket before exposing +structured tools. The server process **MUST** retain that socket for its +lifetime. A structured tool **MUST NOT** accept a per-call socket selector. A +structured tool **MUST NOT** address tmux objects outside the selected socket. + +The default configuration **MUST** select a product-scoped dedicated socket. A +server process that creates the tmux server on that socket **MUST** use a minimal +tmux configuration. Selecting an inherited or user-configured socket **MUST** +require explicit operator configuration. A server process that finds an existing +server on the selected socket **MUST NOT** claim that server has minimal +configuration provenance. + +The selected socket limits which tmux sessions, windows, and panes structured +tools can name. It does not restrict filesystem access, process execution, +network access, credentials, or access through commands running in a pane. + +**Rationale, informative.** Socket pinning prevents accidental cross-server +object selection. It does not provide operating-system isolation and must not be +described as a sandbox. + +### CM-2: Independent properties describe each tool's direct capability + +Every structured tool **MUST** declare its direct process reach, direct tmux +effect, and output classes. An implementation **MUST NOT** collapse those +properties into an ordered safety level. + +Direct process reach **MUST** use one of these values: + +- `none`: starts no workload process and delivers no client-controlled input to + one. +- `configured-process`: starts a pane workload process without accepting a + command payload or client-controlled tmux-format input. +- `pane-input`: delivers client-controlled keys or text to a pane program. +- `pane-command`: runs a client-authored shell command in a pane. + +A conforming implementation **MUST** reserve `host-command` to describe a +prohibited public capability. + +Direct tmux effect **MUST** be a set containing one or more of `observe`, +`change`, and `delete`. Output classes **MUST** be a set drawn from +`tmux-metadata`, `terminal-content`, `process-environment`, and +`configured-command`. An implementation **MUST** separately record whether a +tool may expose secrets or return untrusted content. + +These properties describe the direct operation. Whole-call MCP annotations +separately account for execution or mutation added by configured aliases and +hooks. + +**Example, informative.** `rename_window` changes tmux state without accepting +executable input. `run_shell_command` also changes tmux state and accepts a +client-authored pane command. Their tmux effects overlap; their process reach +does not. + +### CM-3: One startup decision defines the advertised and callable tool surface + +Every structured tool **MUST** belong to exactly one unordered toolset: +`inspect`, `manage`, `execute`, or `teardown`. + +On the default dedicated socket with minimal tmux configuration, an +implementation **MUST** enable all four toolsets by default. On an inherited or +user-configured socket, an implementation **MUST** require explicit operator +selection before enabling `teardown`. It **MUST** apply the same requirement to +an existing server whose configuration provenance is unknown. + +At startup, an implementation **MUST** expand the selected toolsets. It **MUST** +then add named inclusions. It **MUST** then remove named exclusions. A named +exclusion **MUST** win over every inclusion path. + +An implementation **MUST** reject unknown toolset and tool names at startup. It +**MUST** freeze the effective structured tool surface for the server process's +lifetime. A tool outside that surface **MUST NOT** be advertised. A tool outside +that surface **MUST NOT** be callable by name. + +An aggregate tool **MAY** invoke a nested operation that is not separately +advertised. The aggregate tool **MUST** declare that nested operation as part of +its own authority. A named exclusion **MUST** remove an operation from every +aggregate tool's nested authority. + +The toolsets support inventory configuration, context reduction, model routing, +and documentation navigation. They do not restrict what an enabled pane-input +or pane-command tool can cause a pane program to do. + +**Rationale, informative.** Unordered toolsets permit combinations such as +`inspect,teardown`. Applying the same effective surface to discovery and +invocation prevents a hidden tool from remaining callable. + +### CM-4: MCP annotations describe an entire tool call + +An implementation **MUST** use standard MCP annotations only with their protocol +meanings. A whole-call MCP annotation **MUST** account for the direct operation +and configured tmux behavior that the operation activates. An implementation +**MUST NOT** use standard annotations as authorization decisions or project +severity labels. + +Direct process reach, direct tmux effect, and output classes **MUST** remain +separate from standard MCP annotations. Without evidence for the whole-call +claim, an implementation **MUST** set `readOnlyHint` to `false`. Without evidence +for the whole-call claim, an implementation **MUST** set `destructiveHint` to +`true`. Without evidence for the whole-call claim, an implementation **MUST** set +`idempotentHint` to `false`. Without evidence for the whole-call claim, an +implementation **MUST** set `openWorldHint` to `true`. + +An implementation **MUST** set `readOnlyHint` to `true` only when the whole call +cannot modify its environment. It **MUST** set `destructiveHint` to `false` only +when the whole call performs additive updates at most. It **MUST** set +`idempotentHint` to `true` only when repeating the call with the same arguments +has no additional effect. It **MUST** set `openWorldHint` to `false` only when the +whole call cannot interact with external entities. + +MCP annotations support client consent interfaces. They do not enforce tool +authorization, operating-system confinement, or command policy. + +### CM-5: One authoritative capability definition governs every public claim + +An implementation **MUST** maintain one authoritative, machine-readable +capability definition for its structured tools. Tool registration, the effective +structured tool surface, tool descriptions, documentation, and capability +reporting **MUST** agree with that definition. + +The capability definition **MUST** classify every caller-controlled input by the +interpreter boundary it reaches. An implementation **MUST** validate capability +claims against those input sinks rather than infer behavior from parameter +names. + +**Rationale, informative.** tmux expands formats in argument positions whose +names do not reveal the interpreter boundary. A shared manifest generated or +validated during CI is one implementation approach, not a required storage +format. + +### CM-6: Public tools do not execute client-authored host commands + +A conforming implementation **MUST NOT** expose a structured tool with +`host-command` process reach. It **MUST NOT** hand caller text directly to a +host-side shell. Client-authored shell commands **MAY** run through a +`pane-command` tool, where the process is represented by a tmux pane. + +This rule constrains the direct MCP surface. It does not prevent a pane command +from invoking tmux's +[`run-shell`](https://github.com/tmux/tmux/blob/3.2a/cmd-run-shell.c#L177-L181), +installing a +[`#()` status job](https://github.com/tmux/tmux/blob/3.2a/format.c#L392-L399), +or starting any process available to the tmux user's account. + +### CM-7: Capability disclosure is part of the product contract + +Every structured tool description **MUST** begin with a plain-language statement +of its direct process reach and tmux effect. An implementation **MUST** publish +its effective structured tool surface and selected socket. Installation and +trust documentation **MUST** state that execute tools run with the tmux user's +authority. + +Documentation **MUST** distinguish socket-scoped object selection from +operating-system confinement. Documentation **MUST** distinguish tool-surface +filtering from authorization. Documentation **MUST** describe whole-call MCP +annotations as consent metadata rather than enforcement. + +An implementation **MUST NOT** describe a dedicated socket, restricted tool +surface, payload filter, or MCP annotation as a sandbox or security boundary. ## Consequences -The conservative annotation policy costs prompt granularity: tmux operations -decline every positive safety claim, so clients may prompt more. Direct-operation -distinctions move into toolset, reach, and the opening sentence, which are ours -to define. - -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. - -Because `exit-empty` defaults on -([`options-table.c`](https://github.com/tmux/tmux/blob/3.7c/options-table.c#L375-L380)), -tmux becomes eligible to exit only after every session is removed; attached -clients can delay exit further -([`server.c`](https://github.com/tmux/tmux/blob/3.7c/server.c#L281-L292)). -Removing one MCP instance's sessions therefore does not stop a shared server -while another session remains. Socket-wide termination stays an operator action -because it may destroy sessions the caller does not own. - -The result is a shared model for tmux MCPs that are useful by default, explicit -that they can execute arbitrary code, precise about the little a socket scopes, -and structured so that a future tool cannot quietly acquire authority its -documentation does not admit to. +Conservative whole-call annotations may cause clients to prompt more often. +That cost preserves the protocol meaning of the annotations. + +Pinning one socket per server process requires separate configured MCP entries +to control separate tmux sockets. The selected socket reduces accidental object +selection without claiming exclusive ownership. + +Maintaining one authoritative capability definition adds review and validation +work. It also makes capability drift detectable across registration, +documentation, and runtime disclosure. + +Stable tool names become part of the client-consent surface. Renaming a public +tool therefore requires an explicit migration rather than an internal refactor. + +## Rejected alternatives + +**An ordered safety scale.** One rank cannot represent independent process +reach, tmux effect, and output sensitivity without hiding one of them. + +**Tool filtering as authorization.** An enabled pane-input or pane-command tool +can express operations omitted from the structured tool surface. Filtering is +still useful for inventory control and accident reduction. + +**Payload blocklists.** Shell and terminal input are composable. A filter that +recognizes selected strings cannot establish a command boundary and would invite +operators to rely on incomplete protection. + +**Per-call socket selection.** A caller-selected socket expands every tool's +object namespace and makes one server process represent several trust contexts. + +**Public host-command tools.** Host-side commands would bypass the observable +pane process and its completion boundary. + +**Generic mutating or destructive aggregates.** A wrapper hides the names on +which client consent policies depend and turns one approval into authority over +unrelated operations. + +## Security and reliability considerations + +### Ambient tmux behavior + +**Background, informative.** tmux +[expands command aliases](https://github.com/tmux/tmux/blob/3.2a/cmd-parse.y#L698-L715) +before dispatch and +[runs after-hooks](https://github.com/tmux/tmux/blob/3.2a/cmd-queue.c#L617-L627) +after many commands. A nominally observational direct operation can therefore +execute or mutate through existing configuration. Independent pane processes, +plugins, event hooks, and status jobs can also run without an MCP call. + +A conforming implementation **MUST NOT** claim that it prevents all subprocess +execution. A conforming implementation **MUST** describe ambient tmux behavior +separately from direct process reach. + +### Shared sockets + +**Boundary, informative.** Every process with access to the selected socket can +create or alter objects on it. A dedicated socket separates the structured +namespace from another tmux server; it does not establish exclusive ownership. + +An implementation **MUST NOT** claim ownership of every object on a shared +socket. A self-kill guard **MUST** be described as protection against direct +teardown tools only, not against equivalent pane commands or other clients. + +**Shutdown behavior, informative.** With the default `exit-empty` enabled and +`exit-unattached` disabled +([tmux option defaults](https://github.com/tmux/tmux/blob/3.2a/options-table.c#L256-L268)), +tmux waits for every session to be removed and for clients to disconnect before +exiting +([tmux exit logic](https://github.com/tmux/tmux/blob/3.2a/server.c#L268-L286)). +Removing one MCP instance's sessions does not stop a shared server while another +session remains. Socket-wide termination remains an operator action because it +may destroy sessions the caller does not own. + +### Untrusted and sensitive output + +**Background, informative.** Terminal output, environment values, configured +commands, and tmux metadata can contain secrets or untrusted instructions. + +An `inspect` tool **MUST NOT** be described as safe merely because its direct +operation is observational. An implementation **MUST** disclose when a tool may +return untrusted content. It **MUST** disclose when a tool may expose secrets. + +### Aggregate authority + +An aggregate tool **MUST** disclose the complete set of nested tools it can +invoke. Its effective authority **MUST NOT** exceed its advertised nested tool +set. A conforming implementation **MUST NOT** expose a generic mutating or +destructive aggregate. + +### Bounded operations + +An implementation that claims bounded pattern matching **MUST** bound both input +size and matching execution time. A capture or transport deadline **MUST NOT** be +misreported as a matching timeout. + +### Redaction and history suppression + +**Boundary, informative.** Audit redaction applies only to the audit record. +History suppression is best-effort shell hygiene. + +An implementation **MUST NOT** claim that either mechanism removes data from +client transcripts, pane scrollback, process arguments, shell history outside +its control, or operating-system observation surfaces. + +## When to reconsider + +Reconsider this decision if MCP gains enforceable authorization or nested-call +annotation semantics, if tmux exposes a public command mode that suppresses all +relevant ambient behavior, or if deployments require a stronger multi-user +boundary than one operating-system account and socket can provide. + +Reconsider the shared vocabulary if an implementation cannot express a required +capability without weakening an existing term. Add a new independent property +instead of stretching an old one into a severity rank. + +## References + +- [Issue #127: tool visibility is not operating-system confinement](https://github.com/tmux-python/libtmux-mcp/issues/127) +- [Source decision for ADR 0001](https://github.com/tmux-python/libtmux-mcp/issues/127#issuecomment-5463431049) +- [Detailed target-state capability model](https://github.com/tmux-python/libtmux-mcp/issues/127#issuecomment-5463166342) diff --git a/tests/docs/test_topic_contracts.py b/tests/docs/test_topic_contracts.py index 8fa8751d..73c4eac1 100644 --- a/tests/docs/test_topic_contracts.py +++ b/tests/docs/test_topic_contracts.py @@ -358,13 +358,23 @@ def test_capability_adr_preserves_the_source_decision( ) assert "(adr-capability-model)=" in text - assert "# ADR 0001: Capability model" in text + assert text.startswith("(adr-capability-model)=\n\n# ADR 0001:") assert "## Status\n\nProposed." in text assert "issues/127#issuecomment-5463431049" in text for number in range(1, 8): - assert f"**CM-{number} —" in text - assert "## What this does not guarantee" in text - assert "## Consequences" in text + assert f"### CM-{number}:" in text + for section in ( + "## Abstract", + "## Scope and non-goals", + "## Terminology", + "## Conformance", + "## Architectural decisions", + "## Consequences", + "## Rejected alternatives", + "## Security and reliability considerations", + "## When to reconsider", + ): + assert section in text def test_trust_toolset_labels_are_badged_and_copyable(