diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 5789723..31b739f 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -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>, +} + +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, state: Arc>, pending: Arc, + rate_limiter: Arc, app: AppHandle, tool_router: ToolRouter, } @@ -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, + /// "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, +} + +#[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, + /// Same semantics as spawn_pane.orientation. + #[serde(default)] + pub orientation: Option, +} + +#[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, state: Arc>, pending: Arc, + rate_limiter: Arc, 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, + ) -> Result { + 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, + ) -> Result { + // 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, + ) -> Result { + 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 = 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(), )) }, diff --git a/src-tauri/src/mcp_policy.rs b/src-tauri/src/mcp_policy.rs index 27d8b3f..d777a76 100644 --- a/src-tauri/src/mcp_policy.rs +++ b/src-tauri/src/mcp_policy.rs @@ -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(), } } diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 41b6b96..93adb0b 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -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 { diff --git a/src/App.tsx b/src/App.tsx index 05c66a8..d45aee0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { writeToPane, killPane, type PaneId, + type SpawnSpec, type SshHost, type McpStatus, type McpMirror, @@ -35,7 +36,9 @@ import { type LeafNode, type LeafShellSpec, newLeaf, + newId, splitLeaf, + splitLeafWith, closeLeaf, findLeaf, leafCount, @@ -94,6 +97,21 @@ function defaultShellAsLeafProps(d: DefaultShell): Partial { return { shellKind: "wsl", distro: d.distro }; } +/** Cap a string for display in modals / audit summaries. Single-line, max + * 60 visible chars, control characters escaped so secrets pasted into a + * write_pane call don't print as gibberish in the modal. */ +function truncateForSummary(s: string, cap = 60): string { + const oneLine = s.replace(/\r?\n/g, "\\n").replace(/\t/g, "\\t"); + return oneLine.length > cap ? oneLine.slice(0, cap) + "…" : oneLine; +} + +/** Short human-readable form of a SpawnSpec, used in MCP confirm summaries. */ +function describeSpec(spec: SpawnSpec): string { + if (spec.kind === "wsl") return `WSL${spec.distro ? ` (${spec.distro})` : ""}`; + if (spec.kind === "powershell") return "PowerShell"; + return `SSH${spec.host ? ` to ${spec.host}` : ""}`; +} + export default function App() { // ---- top-level state ----------------------------------------------------- const [tree, setTree] = useState(() => newLeaf()); @@ -577,14 +595,134 @@ export default function App() { return () => window.removeEventListener("keydown", onKey, true); }, [split, close, toggleBroadcast, promoteActive]); + // Waiters keyed by leaf id — used by the MCP spawn_pane / connect_host + // handlers, which must reply with the new paneId but can only get one + // after the freshly-mounted XtermPane completes its spawn round-trip and + // calls back into registerPaneId. + const pendingPaneRegistrations = useRef void>>( + new Map(), + ); + const registerPaneId = useCallback( (leafId: NodeId, paneId: PaneId | null) => { - if (paneId == null) paneIdByLeafRef.current.delete(leafId); - else paneIdByLeafRef.current.set(leafId, paneId); + if (paneId == null) { + paneIdByLeafRef.current.delete(leafId); + return; + } + paneIdByLeafRef.current.set(leafId, paneId); + const waiter = pendingPaneRegistrations.current.get(leafId); + if (waiter) { + pendingPaneRegistrations.current.delete(leafId); + waiter(paneId); + } }, [], ); + /** Insert a new leaf into the tree from a SpawnSpec — used by the MCP + * spawn_pane and connect_host handlers. Returns the new leaf's id + * (caller awaits waitForPaneRegistration on it for the paneId). + * - parentLeafId: defaults to active leaf, then first leaf in layout. + * - orientation: "h" / "v" / undefined; undefined picks smart-orient by + * parent pane aspect (matches the titlebar "+" button's behaviour). + * - New leaf is auto-marked mcpAllow=true so Claude can immediately + * interact with the pane it just spawned. */ + const spawnNewLeafFromSpec = useCallback( + async ( + spec: SpawnSpec, + parentLeafId: string | undefined, + orientationArg: string | undefined, + ): Promise => { + const layout = flattenLayout(treeRef.current); + const parentId = + parentLeafId ?? activeLeafId ?? layout.leaves[0]?.leaf.id ?? null; + if (!parentId) throw new Error("no pane available to split off"); + const parent = findLeaf(treeRef.current, parentId); + if (!parent || parent.kind !== "leaf") { + throw new Error(`parent leaf not found: ${parentId}`); + } + + let orient: Orientation; + if (orientationArg === "h" || orientationArg === "v") { + orient = orientationArg; + } else if (orientationArg == null) { + // Smart-orient: split along the longer side of the parent's pane. + const container = paneWrapRef.current; + const slot = layout.leaves.find((s) => s.leaf.id === parentId); + if (container && slot) { + const rect = container.getBoundingClientRect(); + const paneW = slot.box.width * rect.width; + const paneH = slot.box.height * rect.height; + orient = paneW >= paneH ? "h" : "v"; + } else { + orient = "h"; // safe fallback + } + } else { + throw new Error( + `invalid orientation: ${orientationArg} (expected "h" or "v")`, + ); + } + + // For SSH spawns, the auto-allow safeguard decides whether the new + // pane starts MCP-allowed (Claude can interact immediately) or + // mcpAllow=off (user must explicitly toggle 🤖 to grant access). + // Local shells (WSL / PowerShell) are auto-allowed unconditionally. + let mcpAllow = true; + if (spec.kind === "ssh") { + try { + const policy = await mcpPolicyLoad(); + mcpAllow = policy.sshSafeguards.autoAllowSpawnedSsh; + } catch (e) { + console.warn("policy load failed during ssh spawn, defaulting mcpAllow=false:", e); + mcpAllow = false; + } + } + + const id = newId(); + const newLeafNode: LeafNode = { + kind: "leaf", + id, + shellKind: spec.kind, + mcpAllow, + ...(spec.kind === "wsl" + ? { distro: spec.distro, cwd: spec.cwd } + : {}), + ...(spec.kind === "ssh" && spec.hostId + ? { sshHostId: spec.hostId } + : {}), + }; + setTree((t) => splitLeafWith(t, parentId, orient, newLeafNode)); + return id; + }, + [activeLeafId], + ); + + /** Resolves to the paneId once XtermPane finishes mounting and the + * spawn_pane Tauri command returns. Rejects after timeoutMs. */ + function waitForPaneRegistration( + leafId: NodeId, + timeoutMs = 5000, + ): Promise { + return new Promise((resolve, reject) => { + const existing = paneIdByLeafRef.current.get(leafId); + if (existing != null) { + resolve(existing); + return; + } + pendingPaneRegistrations.current.set(leafId, resolve); + setTimeout(() => { + if (pendingPaneRegistrations.current.has(leafId)) { + pendingPaneRegistrations.current.delete(leafId); + reject( + new Error( + `spawn timed out after ${timeoutMs}ms — pane never registered`, + ), + ); + } + }, timeoutMs); + }); + } + const broadcastFrom = useCallback( (originLeafId: NodeId, dataB64: string) => { let peers = 0; @@ -829,6 +967,81 @@ export default function App() { summary: `Rename pane "${before}" → "${after}"`, }; } + case "write_pane": { + const a = args as { leafId?: string; text?: string }; + if (typeof a.leafId !== "string") throw new Error("missing leafId"); + if (typeof a.text !== "string") throw new Error("missing text"); + const leaf = findLeaf(treeRef.current, a.leafId); + if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`); + const paneId = paneIdByLeafRef.current.get(a.leafId); + if (paneId == null) throw new Error(`no live pane for leaf ${a.leafId}`); + // UTF-8 encode → base64 (matches the existing writeToPane wire shape). + const bytes = new TextEncoder().encode(a.text); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const b64 = btoa(binary); + await writeToPane(paneId, b64); + const labelStr = leaf.label ?? a.leafId.slice(0, 8); + return { + payload: { leafId: a.leafId, bytesWritten: bytes.length }, + summary: `Send to pane "${labelStr}": ${truncateForSummary(a.text)}`, + }; + } + case "spawn_pane": { + const a = args as { + spec?: SpawnSpec; + parentLeafId?: string; + orientation?: string; + }; + if (!a.spec || typeof a.spec !== "object") { + throw new Error("missing spec"); + } + // For SSH, our LeafNode only persists sshHostId — we don't store + // inline host/user/port on the leaf. Refuse ad-hoc SSH spawns; + // use connect_host with a saved host_id instead. + if (a.spec.kind === "ssh" && !a.spec.hostId) { + throw new Error( + "spawn_pane with kind=ssh requires hostId (a saved host). " + + "Use connect_host(host_id) or add the host via the SSH host manager first.", + ); + } + const newLeafId = await spawnNewLeafFromSpec( + a.spec, + a.parentLeafId, + a.orientation, + ); + // 15s covers a cold WSL distro start (~3-5s typical, longer if + // the distro hasn't been used recently). + const paneId = await waitForPaneRegistration(newLeafId, 15000); + return { + payload: { leafId: newLeafId, paneId }, + summary: `Spawn ${describeSpec(a.spec)} pane`, + }; + } + case "connect_host": { + const a = args as { + hostId?: string; + parentLeafId?: string; + orientation?: string; + }; + if (typeof a.hostId !== "string") throw new Error("missing hostId"); + const host = hosts.find((h) => h.id === a.hostId); + if (!host) throw new Error(`unknown host_id: ${a.hostId}`); + const newLeafId = await spawnNewLeafFromSpec( + { kind: "ssh", host: host.hostname, hostId: host.id }, + a.parentLeafId, + a.orientation, + ); + // 30s covers SSH handshake + password auth on a slow or + // first-time connection. + const paneId = await waitForPaneRegistration(newLeafId, 30000); + return { + payload: { leafId: newLeafId, paneId, hostId: host.id }, + summary: `Connect SSH to "${host.label}" (${host.hostname})`, + }; + } case "close_pane": { const a = args as { leafId?: string }; if (typeof a.leafId !== "string") throw new Error("missing leafId"); @@ -931,45 +1144,93 @@ export default function App() { throw new Error(`unsupported MCP tool: ${tool}`); } }, - [setLabel, close, defaultShell, activeLeafId], + [setLabel, close, defaultShell, activeLeafId, hosts, spawnNewLeafFromSpec], ); // The summary string for the confirm modal needs access to the leaf // metadata, so we compute it up-front by partially running the handler // logic (without mutating). For now we just rebuild it inline per tool; // when more tools land this should split out. - const buildConfirmSummary = useCallback((tool: string, args: unknown): string => { - function leafLabel(id: string | undefined): string { - if (!id) return "(unknown)"; - const l = findLeaf(treeRef.current, id); - return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8); - } - switch (tool) { - case "set_label": { - const a = args as { leafId?: string; label?: string }; - return `Rename pane "${leafLabel(a.leafId)}" → "${a.label || "(cleared)"}"`; + const buildConfirmInfo = useCallback( + (tool: string, args: unknown): { summary: string; ssh?: { hostLabel: string } } => { + function leafLabel(id: string | undefined): string { + if (!id) return "(unknown)"; + const l = findLeaf(treeRef.current, id); + return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8); } - case "close_pane": { - const a = args as { leafId?: string }; - return `Close pane "${leafLabel(a.leafId)}"`; + function sshContextForLeaf(id: string | undefined): { hostLabel: string } | undefined { + if (!id) return undefined; + const l = findLeaf(treeRef.current, id); + if (!l || l.kind !== "leaf" || l.shellKind !== "ssh") return undefined; + const host = hosts.find((h) => h.id === l.sshHostId); + return { hostLabel: host ? `${host.label} (${host.hostname})` : (l.sshHostId ?? "?") }; } - case "swap_panes": { - const a = args as { leafA?: string; leafB?: string }; - return `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`; + switch (tool) { + case "set_label": { + const a = args as { leafId?: string; label?: string }; + return { + summary: `Rename pane "${leafLabel(a.leafId)}" → "${a.label || "(cleared)"}"`, + }; + } + case "write_pane": { + const a = args as { leafId?: string; text?: string }; + return { + summary: `Send to pane "${leafLabel(a.leafId)}": ${truncateForSummary(a.text ?? "")}`, + ssh: sshContextForLeaf(a.leafId), + }; + } + case "spawn_pane": { + const a = args as { spec?: SpawnSpec }; + const summary = a.spec ? `Spawn ${describeSpec(a.spec)} pane` : "Spawn pane"; + const ssh = + a.spec && a.spec.kind === "ssh" + ? { + hostLabel: a.spec.hostId + ? hosts.find((h) => h.id === a.spec!.hostId)?.label ?? a.spec.host + : a.spec.host, + } + : undefined; + return { summary, ssh }; + } + case "connect_host": { + const a = args as { hostId?: string }; + const host = a.hostId ? hosts.find((h) => h.id === a.hostId) : null; + const name = host ? `"${host.label}" (${host.hostname})` : a.hostId; + return { + summary: `Connect SSH to ${name}`, + ssh: host ? { hostLabel: `${host.label} (${host.hostname})` } : undefined, + }; + } + case "close_pane": { + const a = args as { leafId?: string }; + return { + summary: `Close pane "${leafLabel(a.leafId)}"`, + ssh: sshContextForLeaf(a.leafId), + }; + } + case "swap_panes": { + const a = args as { leafA?: string; leafB?: string }; + return { + summary: `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`, + }; + } + case "promote_pane": { + const a = args as { leafId?: string }; + return { + summary: `Promote pane "${leafLabel(a.leafId)}" up one level`, + }; + } + case "apply_preset": { + const a = args as { name?: string; allowDrops?: boolean }; + const suffix = a.allowDrops ? " (drops allowed)" : ""; + return { summary: `Reshape workspace to ${a.name}${suffix}` }; + } + default: + return { summary: `Run ${tool}` }; } - case "promote_pane": { - const a = args as { leafId?: string }; - return `Promote pane "${leafLabel(a.leafId)}" up one level`; - } - case "apply_preset": { - const a = args as { name?: string; allowDrops?: boolean }; - const suffix = a.allowDrops ? " (drops allowed)" : ""; - return `Reshape workspace to ${a.name}${suffix}`; - } - default: - return `Run ${tool}`; - } - }, []); + }, + [hosts], + ); useEffect(() => { let cancelled = false; @@ -977,12 +1238,13 @@ export default function App() { void onMcpRequest(async (req: McpActionRequest) => { try { if (req.needsConfirm) { - const summary = buildConfirmSummary(req.tool, req.args); + const info = buildConfirmInfo(req.tool, req.args); const ok = await requestConfirm({ tool: req.tool, args: req.args, reason: req.reason, - summary, + summary: info.summary, + ssh: info.ssh, }); if (!ok) { await mcpActionReply(req.requestId, { Err: "user rejected" }); @@ -1002,7 +1264,7 @@ export default function App() { cancelled = true; if (unlisten) unlisten(); }; - }, [runMcpHandler, requestConfirm, buildConfirmSummary]); + }, [runMcpHandler, requestConfirm, buildConfirmInfo]); const applyPreset = useCallback( (make: (d: Partial) => TreeNode) => { diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..aad1e00 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,84 @@ +import { Component, type ReactNode } from "react"; + +interface Props { + children: ReactNode; + /** Optional label for the error message ("Policy tab", "Audit log", etc.). */ + label?: string; +} + +interface State { + error: Error | null; +} + +/** Last-resort guard against React render exceptions. Without this, a single + * bad render in any component blanks the entire app — react unmounts the + * whole tree because the exception bubbles past the root. Wrap the App + * body or individual high-risk components (PolicyTab, AuditTab) with this. */ +export default class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: { componentStack?: string | null }) { + // Surface to dev tools console — Tauri's WebView2 will show this in + // its inspector. Keeps the diagnostic accessible even if the panel + // refuses to render. + console.error("[ErrorBoundary]", this.props.label ?? "(unlabelled)", error, info); + } + + handleReset = () => { + this.setState({ error: null }); + }; + + render() { + if (this.state.error) { + return ( +
+
+ {this.props.label ?? "Component"} crashed while rendering +
+
+            {this.state.error.message}
+          
+ +
+ ); + } + return this.props.children; + } +} diff --git a/src/components/McpConfirm.tsx b/src/components/McpConfirm.tsx index 0155645..063408d 100644 --- a/src/components/McpConfirm.tsx +++ b/src/components/McpConfirm.tsx @@ -7,6 +7,11 @@ export interface McpConfirmSpec { /** Human-readable summary of what's about to happen, computed by the * per-tool handler (e.g. "rename pane 'shell' to 'build'"). */ summary: string; + /** Set when the action targets (or spawns) an SSH-connected pane. The + * modal renders an extra warning banner — SSH targets bypass our + * in-app safety net since the remote shell expands aliases/subshells + * before executing, and the policy engine only sees the bytes we send. */ + ssh?: { hostLabel: string }; } interface McpConfirmProps { @@ -49,6 +54,16 @@ export default function McpConfirm({ spec, onAccept, onReject, onAlwaysAllow }:
+ {spec.ssh && ( +
+ SSH target — extra caveats apply.{" "} + This runs on the remote host {spec.ssh.hostLabel}. + The pattern matching in your policy only sees the bytes + tiletopia sends; the remote shell expands aliases, subshells, + and variables before executing. The hard-deny list still + applies, but treat this as best-effort, not a sandbox. +
+ )}

{spec.summary}

{spec.reason && (

diff --git a/src/components/McpPanel.css b/src/components/McpPanel.css index d8bf492..4641d2f 100644 --- a/src/components/McpPanel.css +++ b/src/components/McpPanel.css @@ -710,3 +710,68 @@ color: #ccd; border-color: #4488cc; } + +.mcp-confirm-ssh-warn { + background: #2a1a1a; + border: 1px solid #a04040; + border-radius: 4px; + padding: 8px 10px; + margin: 0 0 10px; + color: #e0a0a0; + font-size: 11px; + line-height: 1.5; +} +.mcp-confirm-ssh-warn strong { color: #ff8080; } +.mcp-confirm-ssh-warn code { + background: #0c0c0c; + padding: 1px 4px; + border-radius: 2px; + color: #ffcccc; +} +.mcp-confirm-ssh-warn em { color: #ffd0a0; font-style: normal; } + +/* ---- SSH safeguards section ------------------------------------------- */ + +.policy-ssh-safeguards { + background: #1a1410; + border: 1px solid #4a2a1a; + border-radius: 4px; + padding: 10px 12px; + margin-bottom: 12px; +} +.policy-ssh-safeguards .policy-bucket-header { + color: #d8a040; + border-bottom-color: #3a2a1a; + margin-bottom: 8px; +} + +.policy-toggle-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 0; + cursor: pointer; + border-top: 1px solid #2a1a10; +} +.policy-toggle-row:first-of-type { border-top: none; } +.policy-toggle-row input[type="checkbox"] { + margin-top: 3px; + accent-color: #d8a040; + flex-shrink: 0; +} +.policy-toggle-text { + font-size: 11px; + color: #b8a890; + line-height: 1.45; +} +.policy-toggle-text strong { color: #d8a040; display: block; margin-bottom: 2px; } +.policy-toggle-text code { + background: #0c0c0c; + padding: 1px 4px; + border-radius: 2px; + font-family: inherit; + color: #ffcc80; +} +.policy-toggle-row input:disabled + .policy-toggle-text { + opacity: 0.5; +} diff --git a/src/components/McpPanel.tsx b/src/components/McpPanel.tsx index 4edb3c4..d00592f 100644 --- a/src/components/McpPanel.tsx +++ b/src/components/McpPanel.tsx @@ -5,6 +5,7 @@ import { import type { McpStatus, McpAuditEntry } from "../ipc"; import AuditTab from "./AuditTab"; import PolicyTab from "./PolicyTab"; +import ErrorBoundary from "./ErrorBoundary"; import "./McpPanel.css"; interface McpPanelProps { @@ -294,10 +295,16 @@ export default function McpPanel({ )} {tab === "audit" && ( - + + + )} - {tab === "policy" && } + {tab === "policy" && ( + + + + )}

diff --git a/src/components/PolicyTab.tsx b/src/components/PolicyTab.tsx index 840ff66..6ab3b26 100644 --- a/src/components/PolicyTab.tsx +++ b/src/components/PolicyTab.tsx @@ -126,6 +126,16 @@ export default function PolicyTab() { })); } + function setSshSafeguard( + key: "allowOpenSsh" | "autoAllowSpawnedSsh", + value: boolean, + ) { + mutate((p) => ({ + ...p, + sshSafeguards: { ...p.sshSafeguards, [key]: value }, + })); + } + async function handleSave() { if (!policy || !dirty || saving) return; setSaving(true); @@ -166,6 +176,40 @@ export default function PolicyTab() { +
+
SSH safeguards
+ + +
+
{(["deny", "ask", "allow"] as Bucket[]).map((bucket) => ( => diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index ea70fb2..ce383cd 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -71,7 +71,7 @@ export interface SplitNode { export type TreeNode = LeafNode | SplitNode; -function newId(): NodeId { +export function newId(): NodeId { return ( globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 12) @@ -158,6 +158,22 @@ export function splitLeaf( }); } +/** Like {@link splitLeaf} but inserts a caller-constructed LeafNode (with a + * predetermined id) rather than minting a fresh one. Used by the MCP + * spawn_pane handler which needs the id up-front so it can wait for the + * matching registerPaneId call before replying to the backend. */ +export function splitLeafWith( + root: TreeNode, + leafId: NodeId, + orientation: Orientation, + leaf: LeafNode, +): TreeNode { + return replaceById(root, leafId, (node) => { + if (node.kind !== "leaf") return node; + return newSplit(orientation, node, leaf); + }); +} + /** * Remove the leaf with the given id. The other child of its parent split * takes the parent's place in the tree. Returns null if the closed leaf diff --git a/src/main.tsx b/src/main.tsx index be7a6f4..6c310ed 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,12 +2,15 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./styles.css"; import App from "./App"; +import ErrorBoundary from "./components/ErrorBoundary"; const root = document.getElementById("root"); if (!root) throw new Error("No #root element found"); createRoot(root).render( - + + + );