Replace safety tiers with capability toolsets - #128
Merged
Merged
Conversation
why: tmux expands `-c` as a format before using it as a working
directory (spawn.c), so `#(...)` in `start_directory` ran a shell
job, and a directory whose real name held a `#` silently landed the
pane in `$HOME` instead. All four spawn tools carried the sink while
advertising `destructiveHint: false` and `openWorldHint: false`.
what:
- Resolve `start_directory` to an existing directory before tmux
sees it, then escape the result for tmux's format pass
- Refuse `#[`, which tmux hands to the style parser with no escaped
form, rather than silently redirecting the pane
- Fail on a path that does not exist instead of falling back to the
client's directory
- Cover `create_session`, `create_window`, `split_window` and
`respawn_pane`, including directories named `#(id)` and `#{x}`
- State the parameter's contract where it previously restated its
own name
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #128 +/- ##
==========================================
- Coverage 86.98% 86.91% -0.08%
==========================================
Files 46 46
Lines 3834 3874 +40
Branches 577 583 +6
==========================================
+ Hits 3335 3367 +32
- Misses 350 355 +5
- Partials 149 152 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
why: `search_panes` compiled a caller's pattern with the stdlib `re`,
which has no execution ceiling. A pattern of eleven characters against
an ordinary pane line hung the server indefinitely — `(a|a)+$` over 60
`a` characters never returned — and the tool is `readonly`, so the
hang was reachable at the most restrictive safety tier.
what:
- Compile the caller's pattern with `regex`, which accepts a
per-search wall-clock budget
- Share one 2 s deadline across every line of every captured pane, so
the ceiling covers the call rather than each line
- Report exhaustion as a correctable error naming the cause
- Keep the tmux-side `#{C:...}` fast path untouched; it never runs a
Python match
A literal scan of 20,000 lines costs ~3.5 ms, so 2 s is a ceiling a
real search never approaches.
why: MCP defines `destructiveHint: false` as a positive claim that a tool performs only additive updates. Six tools that hand a caller's payload to a program advertised it: `send_keys`, `send_keys_batch`, `run_command`, `paste_text`, `paste_buffer`, and `pipe_pane`. Typed input can overwrite a file or end a process, so the claim was false to every connected client. what: - Advertise `destructiveHint: true` on the six input-delivering tools - Move `load_buffer` off that preset: it allocates a fresh buffer and delivers nothing, so it is additive and closed-world, which is what the module docstring already said while the code did otherwise - Assert the claim per tool against the registered surface, and drop the five tests that asserted the presets' contents instead — one of them pinned `destructiveHint: false` in place - Require every tool to advertise all four hints, so a client never falls back on a protocol default
why: The four spawn tools advertised `openWorldHint: false`, but a new pane runs a process with the user's full authority. `split_window` also claimed `destructiveHint: false` while accepting a `shell` command that replaces what the pane would otherwise have run. what: - Advertise `openWorldHint: true` on `create_session`, `create_window`, `split_window` and `respawn_pane` - Move `split_window` and `respawn_pane` onto the payload-carrying hints; both take an authored command - Add a preset for the two spawns that carry no command payload, so `create_session` and `create_window` keep their additive claim - Fold the single-use mutating-destructive preset into the destructive one; the tier tag at the registration site already carried the distinction the name existed to document - Retire the pane-scoped hint tests the surface-wide invariants now cover, including a docstring narrating an earlier preset refactor
why: MCP reserves `destructiveHint: false` for tools that perform only additive updates. Renames, resizes, selections, moves, layouts, titles, options, and environment writes all replace a value tmux already held, and `swap_pane` and `delete_buffer` were additionally mis-preset — a swap exchanges two panes, a delete removes a buffer. what: - Advertise `destructiveHint: true` on the replacement tools, and move `swap_pane`, `enter_copy_mode` and `delete_buffer` onto the removal hints - Give `signal_channel` and `wait_for_channel` an additive preset: a `wait-for` channel latches, replacing nothing - Rename the create preset to say what its one remaining user does, now that the session and window spawns have their own - Assert the claim as a closed set: only five named tools may advertise additive-only updates, so a new tool cannot join them without saying so The coarser hint is accepted, not worked around. A client that gates on `destructiveHint` now prompts for a rename; the signal that separates a rename from a shell command is the tool name and description.
why: The tools that return terminal content advertised `openWorldHint: false`, which tells a client the result came from a closed domain. A pane holds whatever was printed into it — an SSH session, a package manager, another agent — so the text crossed a trust boundary before this server read it. Being read-only says nothing about where the bytes came from. what: - Advertise `openWorldHint: true` on `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `wait_for_text` and `show_buffer` - Carry the same hint on `call_readonly_tools_batch`, which can invoke any of them under its own name - Leave structural reads closed: listings, info, options, hooks and the tmux environment report tmux's own state
why: The hint table was hand-maintained, listed 29 of 56 tools, had no `openWorldHint` column, and went stale the moment the annotations were corrected. Nothing checked it. A paragraph also named a preset that no longer exists. what: - List every tool with all four hints, generated from the registered surface - Assert the table against a freshly registered server, so a hint change that skips the docs fails the suite - Say what `destructiveHint: false` and `openWorldHint: true` claim, and that hints are presentation, not enforcement - Register into a fresh server rather than the production one, whose tier filter is fixed at import and would make the check depend on test ordering
why: The registration list named three of the four MCP hints, and the suite now requires every tool to advertise all four. what: - List `openWorldHint` alongside the other three
why: `pipe_pane` already carried a correct tmux-format escaper, measured against the expander's actual rule: a `#`-run followed by `[` is a style sequence that tmux copies through verbatim, so doubling it corrupts the value. Resolving `start_directory` added a second escaper under the same name that doubled every `#` and refused `#[` because doubling broke it. The refusal was a workaround for the wrong rule. what: - Move the run-aware escape into `_utils` as the one implementation, and have `pipe_pane` call it - Leave `pipe_pane` only the half that is its own: `%` doubling for the `strftime` pass `format_expand_time` adds, which `-c` does not get - Drop the `#[` refusal; a directory named `style#[x]` or `run##[x]` now reaches the pane intact - Cover both forms in the round-trip matrix, which goes red under the naive rule
why: `start_directory` was one of eight arguments tmux runs through `format_single`. The rest were missed. `create_session`'s `session_name` and `window_name`, and `create_window`'s `window_name`, execute a `#(...)` job — reproduced on tmux 3.7d at the default safety tier. `rename_session`, `rename_window` and `set_pane_title` corrupt an ordinary name instead: `w#Sx` became `wplain-namex` and a pane titled `title #H here` became `title d here`. what: - Escape the caller's text on `create_session`, `create_window`, `rename_session`, `rename_window` and `set_pane_title` - Escape the option name on `set_option` and `show_option` together; tmux expands it on both, so escaping one alone would stop a write and its read agreeing - Cover every expanding argument in one file, named for the defect rather than for the spawn tools it started with - Correct the fallback the resolver's docstring described: a cwd that cannot be entered falls back to `$HOME`, then `/`, not the client's directory - Give `search_panes` the `Raises` section its deadline needs, since the docstring is what a calling model reads Ruled out by measurement, not assumption: `-e` environment values and `set-option`'s value expand only under `-F`, which is never passed.
why: The generated schema block picked up the new docstrings, but the hand-written prose an agent reads first said nothing about either new failure mode. what: - Add a gotcha covering the whole class: names are stored literally, option values are not - Say on the four spawn pages that `start_directory` must exist - Say on the search page that matching shares a two-second ceiling and which patterns exhaust it
why: The annotation invariants read the production server, whose tier filter is fixed at `LIBTMUX_SAFETY` at import. Under `LIBTMUX_SAFETY=readonly` 13 of them failed on missing keys rather than on any annotation defect. The docs table gate had already solved this three commits earlier; the fix was not carried across. what: - Register into a fresh server, as `test_topic_contracts` does - Assert `idempotentHint` on the spawn tools, which nothing named: the retired per-tool test had covered `respawn_pane`, and the closed-set invariant checks only `destructiveHint`
why: `signal_channel` and `wait_for_channel` advertised additive, idempotent behaviour. `tmux wait-for` is a consuming latch, measured on 3.7d: after `wait-for -S ch`, the first wait returns and the second blocks, and signalling twice removes the channel so a later wait blocks too. Repeating either call changes what a subsequent wait does, which is neither additive nor idempotent. what: - Advertise both with the removal hints - Narrow the closed additive set to the three tools that earn it, and drop the preset that now has no users - Regenerate the safety table, which the docs gate caught
why: The spawn preset was inserted directly above the payload preset's `#:` block, so Sphinx read the whole run as one docstring on the spawn preset — opening with a rationale about payloads it does not take — and the payload preset rendered with none. what: - Move the payload preset's comment down to sit above it
why: The match deadline starts after compilation, so a pattern's own size was unbounded. The tool also read as if the ceiling covered the call; capturing each pane is a tmux round-trip outside it, and the work still grows with the panes in scope. what: - Cap the pattern length, which is what bounds compilation - Say in the docstring what the deadline covers and what it does not - Record why the shared budget is asserted by construction: the first pane to exhaust it aborts the call under a per-pane budget too, so no test separates them A test asserting the shared budget was written and then removed: it passed with the budget deliberately reset per pane, so it proved nothing.
why: One escaper is not one contract. `pipe_pane` doubles `%` because `pipe-pane` runs its argument through `strftime`; `-c` and the name arguments do not, so the same doubling there would corrupt a literal percent. Nothing held that line, and the function is not idempotent, so a later upstream change could double-escape without a gate noticing. what: - Assert the hash escaper leaves `%` alone, doubles a run once, and compounds when applied twice
why: `readonly` / `mutating` / `destructive` read as a permission ladder and was not one. It ranked unlike powers on one axis, so running a shell command and deleting a window differed by degree rather than by kind, and the tiers accumulated upward: the kill tools could not be enabled without also enabling the typing tools. `readonly` was the worst of the three — a capture returns whatever a pane holds, secrets included, so the name promised a safety the tool never had. what: - Group tools into four unordered toolsets by what they do: `inspect`, `manage`, `execute`, `teardown` - Put anything that hands input to a program or stores a value tmux later runs in `execute`, `set_option` and `set_environment` included - Replace the tier gate with set membership, still fail-closed: a tool carrying no toolset is refused - Delete `LIBTMUX_SAFETY` with a startup error naming its replacements; add `LIBTMUX_TOOLSETS`, `LIBTMUX_TOOLS`, `LIBTMUX_EXCLUDE_TOOLS`, and fail startup on an unknown name rather than falling back - Default to `inspect,manage,execute`: this server still reaches whichever tmux server the environment points at, so deletion stays something an operator asks for by name - Rename the annotation presets off tier words onto tmux effects, and `ReadonlyRetryMiddleware` onto the toolset it actually keys on `LIBTMUX_TOOLSETS=inspect,teardown` is now a legal surface.
why: The tests and docs still taught the ladder the code no longer has. A reader who found `readonly` in the glossary or the landing grid would have learned the wrong model, and the docs contract asserted the old table, so the vocabulary could not be half-removed. what: - Rewrite the safety page as a trust page: what the toolsets group, that `inspect` means "does not interpret your input as a command" rather than "safe", and that dropping a toolset is not containment - Retire the mutating and destructive batch pages, and rename the read batch to match the tool - Sweep the glossary, landing grid, configuration, architecture, troubleshooting, logging, prompting, gotchas and demo pages - Point `LIBTMUX_SAFETY`'s entry at the three variables that replaced it, and the section badge map at the toolsets - Rewrite the tests that encoded a ladder: membership instead of a ceiling, and a spawn refused by the read batch instead of a spawn batched through a wrapper that no longer exists
why: The sweep was done by reading, so it missed 39 files — the README inventory, the contributor guide, thirty tool pages saying "Readonly." under Side effects. A word this easy to reintroduce by habit needs a gate, not a careful reviewer. what: - Add a test asserting no source or page names the retired tiers, with the four MCP hint fields excluded because those are the protocol's vocabulary and stay - Exempt only what must name what it replaced: `CHANGES`, `MIGRATION`, the startup error refusing `LIBTMUX_SAFETY`, the test proving it fires, and the docs redirect - Fix everything it found: README, AGENTS, the per-tool side-effect lines, the batch index, the glossary term, the badge demo, and the test names and docstrings that still described a ladder The gate found more than the sweep did, which is the argument for it.
why: Two a17 entry contracts pinned the label the trust page replaced. what: - Assert the reference those entries now carry
why: A rename has to reach every instance, and an operator upgrading needs the old-to-new map in one place rather than assembled from a changelog entry. what: - Add `MIGRATION.md`: the three environment variables, the tool names, which toolset each tool is in, and a surface the ladder could not express - Say why `set_option` and `set_environment` are in `execute`: tmux runs some stored values later - Say what did not change, so nobody withdraws the MCP hints too
why: gp-sphinx no longer ships a default vocabulary, so a project that declares none renders every tool without a toolset badge. That is the right default there — a docs tool should not badge a project's tools with words it never used — and it makes declaring one this project's job. The pin also moves to an exact rev. A branch ref resolves to whatever that branch pointed at when the cache was warmed, which is not a thing a lockfile should depend on. what: - Declare `fastmcp_toolsets` in `docs/conf.py`, in precedence order, with the tooltip and icon each badge carries - Retitle the hand-written tool groups from Inspect/Act/Destroy to the four toolsets, splitting Act into `manage` and `execute` - Pin `sphinx-autodoc-fastmcp` to a rev rather than a branch
why: The badges rendered transparent with inherited link-blue. The stylesheet keyed its colours on the old tag names, so nothing matched once the tags were renamed. gp-sphinx now ships tones and a project maps its tags onto them. what: - Declare a tone per toolset: inspect green, manage blue, execute amber, teardown red - Move the gp-sphinx pin to the commit carrying tones
why: MCP annotations describe the whole call. A target command alias can replace argv, and an after-hook can extend it. Direct operation hints promised more than an inherited tmux server can guarantee. what: - Give each tmux-requesting tool conservative whole-call annotations - Preserve direct semantics in its toolset classification - Prove list_panes can activate a command alias and an after-hook - Pin tmux wait-for signals as a consuming toggle
why: A tmux alias or hook may act before the command reports an error. Retrying an inspect call could repeat that ambient effect. what: - Remove InspectRetryMiddleware and its retry policy - Keep failed production-wire calls to one attempt - Let the client or operator decide whether to retry
why: A process-wide prefix does not prove buffer ownership. Shutdown cleanup could delete another instance's buffer and trigger configured tmux behavior. what: - Remove automatic buffer deletion from lifespan shutdown - Keep cache cleanup local to the MCP process - Prove shutdown issues no tmux commands
why: Tool filtering must govern both discovery and invocation. Unknown names and unclassified tools must fail closed instead of widening the surface. what: - Validate named includes and exclusions at startup - Make FastMCP visibility authoritative on the wire - Keep middleware as a classified defense-in-depth check - Retire misleading safety-tier wording
why: FastMCP generated list_prompts and get_prompt after visibility classified the catalog. Enabled adapters then disappeared, and direct calls returned Unknown tool. what: - Decorate generated prompt adapters after PromptsAsTools runs - Classify them as inspect with pure, closed-world annotations - Prove both adapters stay visible and callable on the production wire
why: tmux 3.6 can briefly report the relaunched shell before the child crosses exec(2). An immediate pane_current_command assertion therefore flaked while the behavior was correct. what: - Poll the refreshed pane for at most one second - Require the requested sleep process after the transient shell exits
why: Tool filtering was described more strongly than the architecture allows. tmux aliases and hooks can extend a call. Status jobs run without one, and a socket selects an endpoint without confining its processes. what: - Assign non-surprise, consent, configuration, and confinement ownership - Document aliases, hooks, resources, status jobs, and server provenance - Rebuild the catalog around the four direct-semantics toolsets - State visibility, stdio, prompt, buffer, retry, and channel contracts - Pin catalog and topic claims with derived documentation tests
why: The earlier gate matched ordinary words and skipped contributor guidance, skills, scripts, and project configuration. Stale tier language could therefore survive a tree-wide rename. what: - Match retired identifiers, keys, headings, and exact taxonomy phrases - Cover active guidance, skills, scripts, code, tests, and config - Update remaining active guidance and fixtures to the toolset model - Keep CHANGES and MIGRATION as the historical vocabulary homes
Render the classifications with their configured icon and tone. Keep only these labels selectable so copied prose retains the names without changing badge behavior elsewhere.
why: Pane capture latency could exhaust the regex deadline before matching began, contradicting the documented matching-only budget. what: - Carry the remaining aggregate budget only while matching captured lines - Cover delayed capture and catastrophic backtracking behavior
why: tmux format variables include hyphenated option and user-option names on every supported tmux version. what: - Admit hyphens after the first variable-name character - Cover built-in and user-option expansion while retaining modifier and job rejection
why: Enabling only call_read_tools_batch advertised the wrapper while FastMCP visibility hid every nested inspect tool. Explicit exclusions must remain authoritative. what: - Scope visibility and toolset enforcement to the validated nested read call - Keep direct calls hidden and explicit exclusions dominant - Cover the production profile with an isolated tmux namespace
why: Renaming the batch-tool page otherwise leaves existing public links at a deleted route after deployment. what: - Redirect the former call-readonly-tools-batch route to call-read-tools-batch
`fastmcp_toolsets` becomes a `capability` axis, keeping the four terms with their tones, tooltips and icons unchanged. Our tags already sit on one axis — the capability name implies the risk, which is why teardown reads as more than a topic — so one axis says what the vocabulary always meant. The rendered classes now name it: `__axis-capability`, `__capability-inspect`. `fastmcp_section_badge_map` keeps naming bare terms; with a single axis declared, a bare term resolves against it.
The S3 sync replaces the whole site with `--delete`, but the invalidation named only `/index.html`, `/objects.inv` and `/searchindex.js`. Every other page kept serving CloudFront's cached copy until its TTL expired, so a deploy landed in the bucket and stayed invisible for the better part of an hour. A wildcard covers the pages the sync actually replaced, and bills as one path rather than one per page.
why: The toolset badge vocabulary is available in the published docs packages, so the branch can use released artifacts. what: - Pin gp-sphinx, sphinx-autodoc-api-style, and sphinx-autodoc-fastmcp to 0.1.0a38 - Resolve all three packages from PyPI
why: use-local preserved LIBTMUX_SAFETY from a trunk registration, so the branch rejected its own generated server entry. Dropping it unconditionally could widen an old restricted surface. what: - Remove the retired key when LIBTMUX_TOOLSETS defines its replacement - Reject swaps without a replacement and explicit attempts to add it - Cover replacement and already-local paths
why: Users need one upgrade-time account of the new tool inventory, stricter input contracts, and tmux trust boundary. what: - Document safety-tier migration and batch changes - Record format, matching, annotation, retry, and cleanup behavior - Explain trust documentation and mcp_swap migration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
inspect,manage,execute, andteardowntoolsets that FastMCP visibility and server middleware both enforce.display_messageto literals and plain variable references.This implements the current capability-model work described in issue #127.
Changes by area
Capability surface
LIBTMUX_TOOLSETSselects any combination ofinspect,manage,execute, andteardown; the default remainsinspect,manage,execute.LIBTMUX_TOOLSenables individual tools,LIBTMUX_EXCLUDE_TOOLStakes precedence, and unknown names fail startup.LIBTMUX_SAFETYis rejected with the corresponding toolset configuration.call_read_tools_batchacceptsinspecttools only; the write-capable batch wrappers are removed so client rules see each write tool directly.list_promptsandget_prompttools remain visible throughinspect.tmux correctness
start_directorybefore invoking tmux and reject missing paths instead of silently landing in$HOME.search_panesuses a bounded regex engine with a matching-only deadline and a separate pattern-length limit.display_messageaccepts literal text and plain#{variable}references, including hyphenated option names, while rejecting modifiers, conditionals, and jobs.Trust and lifecycle
after-*hooks can replace or extend an operation.executebecause tmux or a future shell can interpret some stored values later.Documentation and development tooling
0.1.0a38packages from PyPI.mcp_swap.pyremoves retired safety settings only when an explicit toolset replacement preserves the intended surface.Design decisions
Migration
LIBTMUX_SAFETY=readonlyLIBTMUX_TOOLSETS=inspectLIBTMUX_SAFETY=mutatingLIBTMUX_TOOLSETS=inspect,manage,executeLIBTMUX_SAFETY=destructiveLIBTMUX_TOOLSETS=inspect,manage,execute,teardowncall_readonly_tools_batchcall_read_tools_batchcall_mutating_tools_batchcall_destructive_tools_batchVerification
$ uv run ruff check .$ uv run ruff format . --check$ uv run mypy .$ uv run pytest -n 5 --reruns 0$ just build-docsTest plan
display_messagegrammar against supported tmux behavior.mcp_swap.pyacross every supported client configuration and available native client.