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).
96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
import { useEffect } from "react";
|
|
|
|
export interface McpConfirmSpec {
|
|
tool: string;
|
|
args: unknown;
|
|
reason: string | null;
|
|
/** 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 {
|
|
spec: McpConfirmSpec;
|
|
onAccept: () => void;
|
|
onReject: () => void;
|
|
/** Approve this call AND add the bare tool name to the policy allow list
|
|
* so future calls of this tool skip the prompt. */
|
|
onAlwaysAllow: () => void | Promise<void>;
|
|
}
|
|
|
|
export default function McpConfirm({ spec, onAccept, onReject, onAlwaysAllow }: McpConfirmProps) {
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
onReject();
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
onAccept();
|
|
}
|
|
}
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [onAccept, onReject]);
|
|
|
|
const argsJson = JSON.stringify(spec.args, null, 2);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
className="backdrop"
|
|
onClick={onReject}
|
|
aria-label="Reject MCP action"
|
|
/>
|
|
<div className="mcp-confirm" role="dialog" aria-label="MCP action confirm">
|
|
<header className="mcp-confirm-header">
|
|
<span className="mcp-confirm-title">
|
|
MCP wants to run <code>{spec.tool}</code>
|
|
</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">
|
|
Policy decision: <em>{spec.reason}</em>
|
|
</p>
|
|
)}
|
|
<details className="mcp-confirm-args">
|
|
<summary>Raw arguments</summary>
|
|
<pre>{argsJson}</pre>
|
|
</details>
|
|
</div>
|
|
<footer className="mcp-confirm-actions">
|
|
<button className="mcp-confirm-reject" onClick={onReject}>
|
|
Reject (Esc)
|
|
</button>
|
|
<button
|
|
className="mcp-confirm-always"
|
|
onClick={() => { void onAlwaysAllow(); }}
|
|
title={`Add "${spec.tool}" to the policy allow list — future calls of this tool won't prompt`}
|
|
>
|
|
Always allow {spec.tool}
|
|
</button>
|
|
<button className="mcp-confirm-accept" onClick={onAccept} autoFocus>
|
|
Approve (Enter)
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|