MCP v2 PR-3: write_pane, spawn_pane, connect_host + SSH safeguards

Three of the highest-power v2 tools, plus a defense-in-depth pass
on SSH-specific risk.

write_pane sends keystrokes (or any bytes) to a pane's PTY. The
policy engine matches against the text content directly so rules
like write_pane(npm test*) match by what would run, and the
compiled-in hard-deny catches rm -rf /, fork bombs, etc. regardless
of policy. Per-pane token-bucket rate limiter (30 calls / 10s,
3/sec refill) prevents a runaway loop from spamming the user with
confirm modals or burning audit-log capacity. The frontend handler
truncates the text in modal/audit summaries to ~60 chars + escapes
control characters so secrets pasted into write_pane don't echo
verbatim into the UI.

spawn_pane mirrors the existing SpawnSpec enum (WSL distro,
PowerShell, SSH) as the tool schema. New splitLeafWith helper
inserts a caller-built LeafNode (with a pre-generated id) so the
handler can await waitForPaneRegistration on that exact leaf before
replying with the resulting {leafId, paneId}. 15s spawn timeout
covers cold-start WSL distros; 30s for connect_host covers SSH
handshake + auth. Outer dispatch timeout bumped 30s → 60s. SSH
spawns without a saved hostId are refused — LeafNode only persists
sshHostId, no inline params, so use connect_host.

connect_host is a thin wrapper that looks up a saved SSH host by
id and routes through the same spawn machinery.

McpConfirm.tsx gains an optional ssh context — when the call
targets or spawns an SSH pane, a red warning banner renders
explaining that pattern matching is best-effort on the bytes we
send (remote shell expands aliases/subshells before executing).
buildConfirmSummary became buildConfirmInfo and returns the SSH
context alongside the summary string.

PR-3.5 — SSH safeguards. Two new switches in the Policy tab,
both off by default, both gated by mcp_policy::SshSafeguards:

  allowOpenSsh: when off, connect_host and spawn_pane(kind=ssh)
    refuse server-side with a clear "ssh-disabled" message pointing
    at the Policy tab. User must open SSH manually via the titlebar
    🔑 picker and toggle 🤖 on to grant Claude access.

  autoAllowSpawnedSsh: when off, an SSH pane Claude spawns starts
    with mcpAllow=false. User must explicitly toggle 🤖 before
    Claude can read scrollback or send keystrokes. The second switch
    is disabled in the UI when the first is off.

The safe-by-default design means a fresh install gives Claude no
ability to autonomously touch SSH — full safety with one click per
level to enable when consciously wanted. Both switches read fresh
per call so policy edits take effect without a server restart.

ErrorBoundary.tsx — last-resort guard against React render
exceptions. Wraps the App root + each MCP panel tab independently
so a bug in one tab doesn't blank the entire app. Shows a small
red error card with the exception message and a "Try again"
button. Caught a serde rename_all bug during PR-3.5 testing where
PolicyTab read policy.sshSafeguards but Rust serialized
ssh_safeguards (snake_case); without the boundary the whole window
went black.

newId() now exported from tree.ts for the splitLeafWith path.
McpPolicy struct gained #[serde(rename_all = "camelCase")] so
sshSafeguards survives the IPC round-trip cleanly; older policy
files without the field still load (serde defaults to safe).
This commit is contained in:
megaproxy 2026-05-26 14:50:06 +01:00
parent 3acad63fb7
commit bf2810a433
12 changed files with 844 additions and 41 deletions

View file

@ -237,6 +237,45 @@ fn truncate_summary(s: &str) -> String {
}
}
// ----------------------------------------------------------------------------
// Write rate limiter (per OWASP LLM06 "Excessive Agency" + MCP spec MUST).
// Token bucket per leaf_id, only applied to write_pane.
// ----------------------------------------------------------------------------
const WRITE_RATE_CAPACITY: f64 = 30.0;
/// Tokens added per second. 30 / 10s = 3.0 → burst of 30, sustained 3/s.
const WRITE_RATE_REFILL_PER_SEC: f64 = 3.0;
#[derive(Default)]
pub struct WriteRateLimiter {
/// leaf_id → (last_refill, tokens). Naive HashMap that never evicts; for
/// a long-running session with many transient panes this could grow, but
/// LeafIds are uuid-shaped and a few hundred entries is fine.
buckets: PlMutex<HashMap<String, (Instant, f64)>>,
}
impl WriteRateLimiter {
/// Try to consume a token for this leaf. Returns Ok on success, Err with
/// the milliseconds to wait until the next token will be available.
pub fn try_consume(&self, leaf_id: &str) -> Result<(), u64> {
let mut buckets = self.buckets.lock();
let now = Instant::now();
let entry = buckets
.entry(leaf_id.to_string())
.or_insert((now, WRITE_RATE_CAPACITY));
let elapsed = now.saturating_duration_since(entry.0).as_secs_f64();
entry.1 = (entry.1 + elapsed * WRITE_RATE_REFILL_PER_SEC).min(WRITE_RATE_CAPACITY);
entry.0 = now;
if entry.1 >= 1.0 {
entry.1 -= 1.0;
Ok(())
} else {
let wait_secs = (1.0 - entry.1) / WRITE_RATE_REFILL_PER_SEC;
Err((wait_secs * 1000.0).ceil() as u64)
}
}
}
// ----------------------------------------------------------------------------
// MCP service: tools + resources.
// ----------------------------------------------------------------------------
@ -246,6 +285,7 @@ pub struct TileService {
ptys: Arc<PtyManager>,
state: Arc<RwLock<McpState>>,
pending: Arc<PendingActions>,
rate_limiter: Arc<WriteRateLimiter>,
app: AppHandle,
tool_router: ToolRouter<Self>,
}
@ -326,6 +366,51 @@ pub struct ApplyPresetArgs {
pub allow_drops: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SpawnPaneArgs {
/// What shell to run in the new pane: WSL distro, PowerShell, or a
/// saved SSH host (or arbitrary ssh args).
pub spec: crate::pty::SpawnSpec,
/// Where to insert the new pane. Defaults to the active pane, or the
/// workspace root if no pane is active. The parent leaf must be
/// MCP-allowed for Claude to target it.
#[serde(default)]
pub parent_leaf_id: Option<LeafId>,
/// "h" → new pane to the right; "v" → new pane below. If omitted,
/// picks based on the parent's current aspect ratio (wider → "h",
/// taller → "v"), matching the titlebar "+" button's behaviour.
#[serde(default)]
pub orientation: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ConnectHostArgs {
/// Stable id of a host saved via the SSH host manager. The new pane
/// inherits the host's user/port/identity-file/etc. and reuses the
/// keyring-stored password (if any) for auto-fill at the SSH prompt.
pub host_id: String,
/// Same semantics as spawn_pane.parent_leaf_id.
#[serde(default)]
pub parent_leaf_id: Option<LeafId>,
/// Same semantics as spawn_pane.orientation.
#[serde(default)]
pub orientation: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct WritePaneArgs {
/// Stable leaf id from the tree (uuid-shaped). Must belong to a pane
/// the user has allow-listed for MCP access.
pub leaf_id: LeafId,
/// Bytes to send to the pane's PTY. Use "\n" for Enter. Each call sends
/// one chunk; partial commands are fine but block the shell until you
/// send a newline. This is the highest-risk MCP tool — Claude can send
/// arbitrary keystrokes including destructive commands. The policy
/// engine + hard-deny list are evaluated against this text directly.
/// Rate-limited to 30 calls / 10s per pane.
pub text: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SetLabelArgs {
/// Stable leaf id from the tree (uuid-shaped). Must belong to a pane
@ -344,12 +429,14 @@ impl TileService {
ptys: Arc<PtyManager>,
state: Arc<RwLock<McpState>>,
pending: Arc<PendingActions>,
rate_limiter: Arc<WriteRateLimiter>,
app: AppHandle,
) -> Self {
Self {
ptys,
state,
pending,
rate_limiter,
app,
tool_router: Self::tool_router(),
}
@ -466,7 +553,11 @@ impl TileService {
let _ = self.app.emit("mcp://request", &payload);
// 6. Await reply with 30s timeout.
let result = tokio::time::timeout(Duration::from_secs(30), rx).await;
// 60s is the outer cap. Per-tool inner timeouts (waitForPaneRegistration
// in the frontend handler) are tighter for fast ops and looser for
// SSH/spawn — this just keeps a misbehaving frontend from leaking a
// request id forever.
let result = tokio::time::timeout(Duration::from_secs(60), rx).await;
let duration_ms = now_ms() - start_ms;
@ -664,6 +755,169 @@ impl TileService {
}
}
#[tool(description = "Send keystrokes (text) to a pane's PTY. The leaf \
must be MCP-allowed. Use \"\\n\" for Enter. Rate-limited per pane: \
30 calls per 10 seconds. The user policy is evaluated against the \
sent text (so rules like write_pane(git push *) match by content), \
and a compiled-in hard-deny list always catches rm -rf /, fork \
bombs, mkfs on devices, etc. those are blocked regardless of \
policy.")]
async fn write_pane(
&self,
Parameters(args): Parameters<WritePaneArgs>,
) -> Result<CallToolResult, McpError> {
let _leaf = self.require_visible_leaf(&args.leaf_id).await?;
// Rate limit BEFORE dispatch — we don't want a misbehaving client
// to spam the user with confirm modals or burn audit-log capacity.
if let Err(retry_after_ms) = self.rate_limiter.try_consume(&args.leaf_id) {
tracing::warn!(
leaf_id = %args.leaf_id,
retry_after_ms,
"write_pane: rate limited"
);
return Err(McpError::invalid_params(
format!("rate limited; retry after {retry_after_ms}ms"),
Some(json!({
"leaf_id": &args.leaf_id,
"retry_after_ms": retry_after_ms,
})),
));
}
// args_repr IS the text — that's what hard-deny and the user's
// policy globs pattern-match against. (For other tools we use a
// stable summary string; for write_pane the text is the surface.)
let args_repr = args.text.clone();
let args_json = json!({ "leafId": &args.leaf_id, "text": &args.text });
tracing::debug!(
leaf_id = %args.leaf_id,
bytes = args.text.len(),
"write_pane: dispatching"
);
let _ = self
.dispatch_action("write_pane", args_json, args_repr)
.await?;
Ok(CallToolResult::success(vec![Content::text("ok")]))
}
#[tool(description = "Spawn a new pane next to an existing one, running \
the requested shell (WSL distro / PowerShell / SSH). The new pane is \
auto-allowed for MCP so Claude can immediately read its scrollback \
and send it keystrokes (subject to policy). Returns {leafId, paneId} \
for the newly created pane. Times out after ~15 seconds if the \
PTY can't be spawned (covers a cold WSL distro start).")]
async fn spawn_pane(
&self,
Parameters(args): Parameters<SpawnPaneArgs>,
) -> Result<CallToolResult, McpError> {
// SSH safeguard: refuse before any other work if the user hasn't
// opted in to MCP-initiated SSH connections.
if matches!(args.spec, crate::pty::SpawnSpec::Ssh { .. })
&& !self.policy_ssh_open_allowed().await
{
return Err(McpError::invalid_params(
"ssh-disabled: Claude is not allowed to open SSH connections \
(Policy tab SSH safeguards 'Allow Claude to open SSH \
connections'). Open the SSH session manually via the \
titlebar 🔑 picker first, then ask Claude to interact with \
it.",
None,
));
}
// If a parent is named, it must be MCP-allowed. (If omitted, the
// frontend picks the active pane or root — no explicit check here
// since the user already approved an MCP-server-running state.)
if let Some(ref parent) = args.parent_leaf_id {
let _ = self.require_visible_leaf(parent).await?;
}
// Serialise the spec back to JSON so the frontend handler can
// reconstruct it without us doing the per-kind dispatch here.
let spec_json = serde_json::to_value(&args.spec).map_err(|e| {
McpError::internal_error(
format!("serialize SpawnSpec: {e}"),
None,
)
})?;
let kind_str = match &args.spec {
crate::pty::SpawnSpec::Wsl { .. } => "wsl",
crate::pty::SpawnSpec::Powershell => "powershell",
crate::pty::SpawnSpec::Ssh { .. } => "ssh",
};
let args_repr = format!(
"shell={} parent={} orientation={}",
kind_str,
args.parent_leaf_id.as_deref().unwrap_or("(active)"),
args.orientation.as_deref().unwrap_or("(auto)"),
);
let args_json = json!({
"spec": spec_json,
"parentLeafId": args.parent_leaf_id,
"orientation": args.orientation,
});
tracing::debug!(shell = kind_str, "spawn_pane: dispatching");
let result = self
.dispatch_action("spawn_pane", args_json, args_repr)
.await?;
Ok(CallToolResult::success(vec![Content::text(
result.to_string(),
)]))
}
#[tool(description = "Open a new pane connected to a saved SSH host. \
Thin wrapper around spawn_pane that resolves host_id against the \
saved-hosts list (use the tiletopia://hosts resource to list them). \
Returns {leafId, paneId} for the new pane. The user's saved password \
for the host (if any) is auto-typed at the prompt passwords are \
never exposed through the MCP surface.")]
async fn connect_host(
&self,
Parameters(args): Parameters<ConnectHostArgs>,
) -> Result<CallToolResult, McpError> {
if !self.policy_ssh_open_allowed().await {
return Err(McpError::invalid_params(
"ssh-disabled: Claude is not allowed to open SSH connections \
(Policy tab SSH safeguards 'Allow Claude to open SSH \
connections'). Open the SSH session manually via the \
titlebar 🔑 picker first, then ask Claude to interact with \
it.",
None,
));
}
if let Some(ref parent) = args.parent_leaf_id {
let _ = self.require_visible_leaf(parent).await?;
}
// Verify host_id is in the mirror (so Claude can't probe unknown ids).
let host_known = {
let st = self.state.read().await;
st.mirror.hosts.iter().any(|h| h.id == args.host_id)
};
if !host_known {
return Err(McpError::invalid_params(
"unknown host_id (use tiletopia://hosts to list saved hosts)",
Some(json!({ "host_id": &args.host_id })),
));
}
let args_repr = format!(
"host={} parent={} orientation={}",
&args.host_id,
args.parent_leaf_id.as_deref().unwrap_or("(active)"),
args.orientation.as_deref().unwrap_or("(auto)"),
);
let args_json = json!({
"hostId": &args.host_id,
"parentLeafId": args.parent_leaf_id,
"orientation": args.orientation,
});
tracing::debug!(host_id = %args.host_id, "connect_host: dispatching");
let result = self
.dispatch_action("connect_host", args_json, args_repr)
.await?;
Ok(CallToolResult::success(vec![Content::text(
result.to_string(),
)]))
}
#[tool(description = "Set or clear the human-readable label on a pane. \
Pass empty string to clear. The leaf must be MCP-allowed.")]
async fn set_label(
@ -782,6 +1036,20 @@ impl TileService {
Ok(CallToolResult::success(vec![Content::text("ok")]))
}
/// Read the persisted SSH-safeguard switch. Fresh-read every call so a
/// user editing the policy in the panel takes effect on the next MCP
/// call without a server restart. Errors fall back to the safe default
/// (refuse).
async fn policy_ssh_open_allowed(&self) -> bool {
match crate::mcp_policy::load_or_init(&self.app) {
Ok(p) => p.ssh_safeguards.allow_open_ssh,
Err(e) => {
tracing::warn!(error = %e, "policy_ssh_open_allowed: load failed, defaulting to false");
false
}
}
}
/// Shared validation for tools that target an existing leaf — confirms
/// the leaf is in the mirror (which means the user has it allow-listed
/// for MCP) and returns its metadata. Factored out of the 4+ tools that
@ -968,6 +1236,9 @@ pub async fn start_server(
let ptys_f = ptys.clone();
let state_f = state.clone();
let pending_f = pending.clone();
// Single shared rate limiter for the lifetime of this server — token
// buckets are per-leaf-id, but the registry itself is one piece of state.
let rate_limiter: Arc<WriteRateLimiter> = Arc::new(WriteRateLimiter::default());
// Clone AppHandle before the move closure so we can pass it into each
// TileService instance. AppHandle is cheap to clone (it's an Arc inside).
let app_handle_for_service = app_handle.clone();
@ -982,6 +1253,7 @@ pub async fn start_server(
ptys_f.clone(),
state_f.clone(),
pending_f.clone(),
rate_limiter.clone(),
app_handle_for_service.clone(),
))
},

View file

@ -13,9 +13,34 @@ use tauri::{AppHandle, Manager};
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpPolicy {
pub version: u32, // currently 1
pub permissions: McpPermissions,
/// SSH-specific safety toggles. Both default to false ("safest") on
/// fresh installs and when loading older policy files that pre-date
/// this section. Surface in the Policy tab's "SSH safeguards" section.
#[serde(default)]
pub ssh_safeguards: SshSafeguards,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SshSafeguards {
/// When false (default), the connect_host and spawn_pane(kind=ssh)
/// MCP tools refuse server-side with a clear error — Claude can't
/// initiate SSH connections at all; the user must open them manually
/// via the titlebar 🔑 picker. Turn on if you want Claude to be able
/// to open saved SSH hosts on its own (e.g. for multi-host work).
#[serde(default)]
pub allow_open_ssh: bool,
/// When false (default), an SSH pane that Claude spawns starts with
/// mcpAllow=false — Claude can't read its scrollback or send it
/// keystrokes until the user explicitly toggles 🤖 on. Turn on if
/// you want full autonomy once you've consented to the open-ssh
/// switch above.
#[serde(default)]
pub auto_allow_spawned_ssh: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@ -329,6 +354,7 @@ pub fn default_policy() -> McpPolicy {
McpPolicy {
version: 1,
permissions: McpPermissions::default(),
ssh_safeguards: SshSafeguards::default(),
}
}

View file

@ -21,7 +21,10 @@ pub type PaneId = u64;
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
/// as `{ kind: "wsl" | "powershell" | "ssh", ... }` from the frontend.
#[derive(Debug, Clone, Deserialize)]
/// Also reused as the schema for the MCP `spawn_pane` tool — `JsonSchema`
/// lets rmcp render it for Claude; `Serialize` lets the backend bounce it
/// back into the `mcp://request` event payload for the frontend handler.
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SpawnSpec {
Wsl {