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
332
src/App.tsx
332
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<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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue