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

@ -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<Props, State> {
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 (
<div
style={{
padding: 14,
margin: 10,
background: "#1a0e0e",
border: "1px solid #6a2a2a",
borderRadius: 4,
color: "#e0a0a0",
font: "12px/1.5 monospace",
}}
role="alert"
>
<div style={{ fontWeight: 600, color: "#ff8080", marginBottom: 6 }}>
{this.props.label ?? "Component"} crashed while rendering
</div>
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
margin: "6px 0",
color: "#c08080",
fontSize: 11,
}}
>
{this.state.error.message}
</pre>
<button
onClick={this.handleReset}
style={{
marginTop: 6,
font: "inherit",
background: "#2a1a1a",
color: "#e0a0a0",
border: "1px solid #6a2a2a",
borderRadius: 3,
padding: "3px 10px",
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View file

@ -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 }:
</span>
</header>
<div className="mcp-confirm-body">
{spec.ssh && (
<div className="mcp-confirm-ssh-warn">
<strong>SSH target extra caveats apply.</strong>{" "}
This runs on the remote host <code>{spec.ssh.hostLabel}</code>.
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 <em>best-effort</em>, not a sandbox.
</div>
)}
<p className="mcp-confirm-summary">{spec.summary}</p>
{spec.reason && (
<p className="mcp-confirm-reason">

View file

@ -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;
}

View file

@ -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" && (
<AuditTab entries={auditEntries} onClear={onClearAudit} />
<ErrorBoundary label="Audit tab">
<AuditTab entries={auditEntries} onClear={onClearAudit} />
</ErrorBoundary>
)}
{tab === "policy" && <PolicyTab />}
{tab === "policy" && (
<ErrorBoundary label="Policy tab">
<PolicyTab />
</ErrorBoundary>
)}
</div>
</div>
</>

View file

@ -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() {
</div>
</div>
<div className="policy-ssh-safeguards">
<div className="policy-bucket-header">SSH safeguards</div>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.allowOpenSsh}
onChange={(e) => setSshSafeguard("allowOpenSsh", e.target.checked)}
/>
<div className="policy-toggle-text">
<strong>Allow Claude to open SSH connections.</strong> When off,
the <code>connect_host</code> and <code>spawn_pane(kind=ssh)</code>
{" "}tools refuse with a clear error. You can still open SSH
sessions manually via the titlebar 🔑 picker, and Claude can
interact with them if you toggle 🤖 on.
</div>
</label>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.autoAllowSpawnedSsh}
onChange={(e) =>
setSshSafeguard("autoAllowSpawnedSsh", e.target.checked)
}
disabled={!policy.sshSafeguards.allowOpenSsh}
/>
<div className="policy-toggle-text">
<strong>Auto-grant Claude access to newly-spawned SSH panes.</strong>{" "}
When off, an SSH pane Claude opens starts with 🤖 off you have
to explicitly toggle it before Claude can read scrollback or send
keystrokes. Only meaningful when the switch above is on.
</div>
</label>
</div>
<div className="policy-buckets">
{(["deny", "ask", "allow"] as Bucket[]).map((bucket) => (
<RuleList