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
|
|
@ -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<HashMap<String, (Instant, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// MCP service: tools + resources.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
@ -246,6 +285,7 @@ pub struct TileService {
|
||||||
ptys: Arc<PtyManager>,
|
ptys: Arc<PtyManager>,
|
||||||
state: Arc<RwLock<McpState>>,
|
state: Arc<RwLock<McpState>>,
|
||||||
pending: Arc<PendingActions>,
|
pending: Arc<PendingActions>,
|
||||||
|
rate_limiter: Arc<WriteRateLimiter>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
tool_router: ToolRouter<Self>,
|
tool_router: ToolRouter<Self>,
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +366,51 @@ pub struct ApplyPresetArgs {
|
||||||
pub allow_drops: bool,
|
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<LeafId>,
|
||||||
|
/// "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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<LeafId>,
|
||||||
|
/// Same semantics as spawn_pane.orientation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub orientation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct SetLabelArgs {
|
pub struct SetLabelArgs {
|
||||||
/// Stable leaf id from the tree (uuid-shaped). Must belong to a pane
|
/// Stable leaf id from the tree (uuid-shaped). Must belong to a pane
|
||||||
|
|
@ -344,12 +429,14 @@ impl TileService {
|
||||||
ptys: Arc<PtyManager>,
|
ptys: Arc<PtyManager>,
|
||||||
state: Arc<RwLock<McpState>>,
|
state: Arc<RwLock<McpState>>,
|
||||||
pending: Arc<PendingActions>,
|
pending: Arc<PendingActions>,
|
||||||
|
rate_limiter: Arc<WriteRateLimiter>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ptys,
|
ptys,
|
||||||
state,
|
state,
|
||||||
pending,
|
pending,
|
||||||
|
rate_limiter,
|
||||||
app,
|
app,
|
||||||
tool_router: Self::tool_router(),
|
tool_router: Self::tool_router(),
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +553,11 @@ impl TileService {
|
||||||
let _ = self.app.emit("mcp://request", &payload);
|
let _ = self.app.emit("mcp://request", &payload);
|
||||||
|
|
||||||
// 6. Await reply with 30s timeout.
|
// 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;
|
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<WritePaneArgs>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<SpawnPaneArgs>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
// 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<ConnectHostArgs>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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. \
|
#[tool(description = "Set or clear the human-readable label on a pane. \
|
||||||
Pass empty string to clear. The leaf must be MCP-allowed.")]
|
Pass empty string to clear. The leaf must be MCP-allowed.")]
|
||||||
async fn set_label(
|
async fn set_label(
|
||||||
|
|
@ -782,6 +1036,20 @@ impl TileService {
|
||||||
Ok(CallToolResult::success(vec![Content::text("ok")]))
|
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
|
/// Shared validation for tools that target an existing leaf — confirms
|
||||||
/// the leaf is in the mirror (which means the user has it allow-listed
|
/// 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
|
/// 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 ptys_f = ptys.clone();
|
||||||
let state_f = state.clone();
|
let state_f = state.clone();
|
||||||
let pending_f = pending.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<WriteRateLimiter> = Arc::new(WriteRateLimiter::default());
|
||||||
// Clone AppHandle before the move closure so we can pass it into each
|
// 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).
|
// TileService instance. AppHandle is cheap to clone (it's an Arc inside).
|
||||||
let app_handle_for_service = app_handle.clone();
|
let app_handle_for_service = app_handle.clone();
|
||||||
|
|
@ -982,6 +1253,7 @@ pub async fn start_server(
|
||||||
ptys_f.clone(),
|
ptys_f.clone(),
|
||||||
state_f.clone(),
|
state_f.clone(),
|
||||||
pending_f.clone(),
|
pending_f.clone(),
|
||||||
|
rate_limiter.clone(),
|
||||||
app_handle_for_service.clone(),
|
app_handle_for_service.clone(),
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,34 @@ use tauri::{AppHandle, Manager};
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct McpPolicy {
|
pub struct McpPolicy {
|
||||||
pub version: u32, // currently 1
|
pub version: u32, // currently 1
|
||||||
pub permissions: McpPermissions,
|
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)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
|
@ -329,6 +354,7 @@ pub fn default_policy() -> McpPolicy {
|
||||||
McpPolicy {
|
McpPolicy {
|
||||||
version: 1,
|
version: 1,
|
||||||
permissions: McpPermissions::default(),
|
permissions: McpPermissions::default(),
|
||||||
|
ssh_safeguards: SshSafeguards::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ pub type PaneId = u64;
|
||||||
|
|
||||||
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
|
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
|
||||||
/// as `{ kind: "wsl" | "powershell" | "ssh", ... }` from the frontend.
|
/// 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")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum SpawnSpec {
|
pub enum SpawnSpec {
|
||||||
Wsl {
|
Wsl {
|
||||||
|
|
|
||||||
290
src/App.tsx
290
src/App.tsx
|
|
@ -19,6 +19,7 @@ import {
|
||||||
writeToPane,
|
writeToPane,
|
||||||
killPane,
|
killPane,
|
||||||
type PaneId,
|
type PaneId,
|
||||||
|
type SpawnSpec,
|
||||||
type SshHost,
|
type SshHost,
|
||||||
type McpStatus,
|
type McpStatus,
|
||||||
type McpMirror,
|
type McpMirror,
|
||||||
|
|
@ -35,7 +36,9 @@ import {
|
||||||
type LeafNode,
|
type LeafNode,
|
||||||
type LeafShellSpec,
|
type LeafShellSpec,
|
||||||
newLeaf,
|
newLeaf,
|
||||||
|
newId,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
|
splitLeafWith,
|
||||||
closeLeaf,
|
closeLeaf,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
leafCount,
|
leafCount,
|
||||||
|
|
@ -94,6 +97,21 @@ function defaultShellAsLeafProps(d: DefaultShell): Partial<LeafNode> {
|
||||||
return { shellKind: "wsl", distro: d.distro };
|
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() {
|
export default function App() {
|
||||||
// ---- top-level state -----------------------------------------------------
|
// ---- top-level state -----------------------------------------------------
|
||||||
const [tree, setTree] = useState<TreeNode>(() => newLeaf());
|
const [tree, setTree] = useState<TreeNode>(() => newLeaf());
|
||||||
|
|
@ -577,14 +595,134 @@ export default function App() {
|
||||||
return () => window.removeEventListener("keydown", onKey, true);
|
return () => window.removeEventListener("keydown", onKey, true);
|
||||||
}, [split, close, toggleBroadcast, promoteActive]);
|
}, [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(
|
const registerPaneId = useCallback(
|
||||||
(leafId: NodeId, paneId: PaneId | null) => {
|
(leafId: NodeId, paneId: PaneId | null) => {
|
||||||
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
|
if (paneId == null) {
|
||||||
else paneIdByLeafRef.current.set(leafId, paneId);
|
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(
|
const broadcastFrom = useCallback(
|
||||||
(originLeafId: NodeId, dataB64: string) => {
|
(originLeafId: NodeId, dataB64: string) => {
|
||||||
let peers = 0;
|
let peers = 0;
|
||||||
|
|
@ -829,6 +967,81 @@ export default function App() {
|
||||||
summary: `Rename pane "${before}" → "${after}"`,
|
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": {
|
case "close_pane": {
|
||||||
const a = args as { leafId?: string };
|
const a = args as { leafId?: string };
|
||||||
if (typeof a.leafId !== "string") throw new Error("missing leafId");
|
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}`);
|
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
|
// The summary string for the confirm modal needs access to the leaf
|
||||||
// metadata, so we compute it up-front by partially running the handler
|
// metadata, so we compute it up-front by partially running the handler
|
||||||
// logic (without mutating). For now we just rebuild it inline per tool;
|
// logic (without mutating). For now we just rebuild it inline per tool;
|
||||||
// when more tools land this should split out.
|
// when more tools land this should split out.
|
||||||
const buildConfirmSummary = useCallback((tool: string, args: unknown): string => {
|
const buildConfirmInfo = useCallback(
|
||||||
|
(tool: string, args: unknown): { summary: string; ssh?: { hostLabel: string } } => {
|
||||||
function leafLabel(id: string | undefined): string {
|
function leafLabel(id: string | undefined): string {
|
||||||
if (!id) return "(unknown)";
|
if (!id) return "(unknown)";
|
||||||
const l = findLeaf(treeRef.current, id);
|
const l = findLeaf(treeRef.current, id);
|
||||||
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
|
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
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 ?? "?") };
|
||||||
|
}
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "set_label": {
|
case "set_label": {
|
||||||
const a = args as { leafId?: string; label?: string };
|
const a = args as { leafId?: string; label?: string };
|
||||||
return `Rename pane "${leafLabel(a.leafId)}" → "${a.label || "(cleared)"}"`;
|
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": {
|
case "close_pane": {
|
||||||
const a = args as { leafId?: string };
|
const a = args as { leafId?: string };
|
||||||
return `Close pane "${leafLabel(a.leafId)}"`;
|
return {
|
||||||
|
summary: `Close pane "${leafLabel(a.leafId)}"`,
|
||||||
|
ssh: sshContextForLeaf(a.leafId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case "swap_panes": {
|
case "swap_panes": {
|
||||||
const a = args as { leafA?: string; leafB?: string };
|
const a = args as { leafA?: string; leafB?: string };
|
||||||
return `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`;
|
return {
|
||||||
|
summary: `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case "promote_pane": {
|
case "promote_pane": {
|
||||||
const a = args as { leafId?: string };
|
const a = args as { leafId?: string };
|
||||||
return `Promote pane "${leafLabel(a.leafId)}" up one level`;
|
return {
|
||||||
|
summary: `Promote pane "${leafLabel(a.leafId)}" up one level`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case "apply_preset": {
|
case "apply_preset": {
|
||||||
const a = args as { name?: string; allowDrops?: boolean };
|
const a = args as { name?: string; allowDrops?: boolean };
|
||||||
const suffix = a.allowDrops ? " (drops allowed)" : "";
|
const suffix = a.allowDrops ? " (drops allowed)" : "";
|
||||||
return `Reshape workspace to ${a.name}${suffix}`;
|
return { summary: `Reshape workspace to ${a.name}${suffix}` };
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return `Run ${tool}`;
|
return { summary: `Run ${tool}` };
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[hosts],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -977,12 +1238,13 @@ export default function App() {
|
||||||
void onMcpRequest(async (req: McpActionRequest) => {
|
void onMcpRequest(async (req: McpActionRequest) => {
|
||||||
try {
|
try {
|
||||||
if (req.needsConfirm) {
|
if (req.needsConfirm) {
|
||||||
const summary = buildConfirmSummary(req.tool, req.args);
|
const info = buildConfirmInfo(req.tool, req.args);
|
||||||
const ok = await requestConfirm({
|
const ok = await requestConfirm({
|
||||||
tool: req.tool,
|
tool: req.tool,
|
||||||
args: req.args,
|
args: req.args,
|
||||||
reason: req.reason,
|
reason: req.reason,
|
||||||
summary,
|
summary: info.summary,
|
||||||
|
ssh: info.ssh,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
await mcpActionReply(req.requestId, { Err: "user rejected" });
|
await mcpActionReply(req.requestId, { Err: "user rejected" });
|
||||||
|
|
@ -1002,7 +1264,7 @@ export default function App() {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (unlisten) unlisten();
|
if (unlisten) unlisten();
|
||||||
};
|
};
|
||||||
}, [runMcpHandler, requestConfirm, buildConfirmSummary]);
|
}, [runMcpHandler, requestConfirm, buildConfirmInfo]);
|
||||||
|
|
||||||
const applyPreset = useCallback(
|
const applyPreset = useCallback(
|
||||||
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
||||||
|
|
|
||||||
84
src/components/ErrorBoundary.tsx
Normal file
84
src/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,11 @@ export interface McpConfirmSpec {
|
||||||
/** Human-readable summary of what's about to happen, computed by the
|
/** Human-readable summary of what's about to happen, computed by the
|
||||||
* per-tool handler (e.g. "rename pane 'shell' to 'build'"). */
|
* per-tool handler (e.g. "rename pane 'shell' to 'build'"). */
|
||||||
summary: string;
|
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 {
|
interface McpConfirmProps {
|
||||||
|
|
@ -49,6 +54,16 @@ export default function McpConfirm({ spec, onAccept, onReject, onAlwaysAllow }:
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="mcp-confirm-body">
|
<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>
|
<p className="mcp-confirm-summary">{spec.summary}</p>
|
||||||
{spec.reason && (
|
{spec.reason && (
|
||||||
<p className="mcp-confirm-reason">
|
<p className="mcp-confirm-reason">
|
||||||
|
|
|
||||||
|
|
@ -710,3 +710,68 @@
|
||||||
color: #ccd;
|
color: #ccd;
|
||||||
border-color: #4488cc;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
import type { McpStatus, McpAuditEntry } from "../ipc";
|
import type { McpStatus, McpAuditEntry } from "../ipc";
|
||||||
import AuditTab from "./AuditTab";
|
import AuditTab from "./AuditTab";
|
||||||
import PolicyTab from "./PolicyTab";
|
import PolicyTab from "./PolicyTab";
|
||||||
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import "./McpPanel.css";
|
import "./McpPanel.css";
|
||||||
|
|
||||||
interface McpPanelProps {
|
interface McpPanelProps {
|
||||||
|
|
@ -294,10 +295,16 @@ export default function McpPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "audit" && (
|
{tab === "audit" && (
|
||||||
|
<ErrorBoundary label="Audit tab">
|
||||||
<AuditTab entries={auditEntries} onClear={onClearAudit} />
|
<AuditTab entries={auditEntries} onClear={onClearAudit} />
|
||||||
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "policy" && <PolicyTab />}
|
{tab === "policy" && (
|
||||||
|
<ErrorBoundary label="Policy tab">
|
||||||
|
<PolicyTab />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
async function handleSave() {
|
||||||
if (!policy || !dirty || saving) return;
|
if (!policy || !dirty || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -166,6 +176,40 @@ export default function PolicyTab() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="policy-buckets">
|
||||||
{(["deny", "ask", "allow"] as Bucket[]).map((bucket) => (
|
{(["deny", "ask", "allow"] as Bucket[]).map((bucket) => (
|
||||||
<RuleList
|
<RuleList
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,12 @@ export interface McpPolicy {
|
||||||
ask: string[];
|
ask: string[];
|
||||||
allow: 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> =>
|
export const mcpPolicyLoad = (): Promise<McpPolicy> =>
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export interface SplitNode {
|
||||||
|
|
||||||
export type TreeNode = LeafNode | SplitNode;
|
export type TreeNode = LeafNode | SplitNode;
|
||||||
|
|
||||||
function newId(): NodeId {
|
export function newId(): NodeId {
|
||||||
return (
|
return (
|
||||||
globalThis.crypto?.randomUUID?.() ??
|
globalThis.crypto?.randomUUID?.() ??
|
||||||
Math.random().toString(36).slice(2, 12)
|
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
|
* 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
|
* takes the parent's place in the tree. Returns null if the closed leaf
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (!root) throw new Error("No #root element found");
|
if (!root) throw new Error("No #root element found");
|
||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ErrorBoundary label="tiletopia">
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue