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:
parent
3acad63fb7
commit
bf2810a433
12 changed files with 844 additions and 41 deletions
|
|
@ -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(),
|
||||
))
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue