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

@ -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<LeafNode> {
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<TreeNode>(() => 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<Map<NodeId, (paneId: PaneId) => 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<NodeId> => {
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<PaneId> {
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<LeafNode>) => TreeNode) => {

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

View file

@ -165,6 +165,12 @@ export interface McpPolicy {
ask: string[];
allow: string[];
};
/** SSH-specific capability switches; mirrors Rust SshSafeguards. Both
* default to false on first load. */
sshSafeguards: {
allowOpenSsh: boolean;
autoAllowSpawnedSsh: boolean;
};
}
export const mcpPolicyLoad = (): Promise<McpPolicy> =>

View file

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

View file

@ -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(
<StrictMode>
<App />
<ErrorBoundary label="tiletopia">
<App />
</ErrorBoundary>
</StrictMode>
);