MCP v2 PR-1: policy engine + audit log + Config/Audit/Policy panel tabs
Foundation for Claude-drives-the-workspace writes. Nothing wired end-to-end yet (App.tsx dispatcher comes next); this lands the machinery + UI. mcp_policy.rs (new) — three-tier allow/ask/deny policy with deny-first precedence and a compiled-in non-overridable hard-deny list (10 patterns covering rm -rf /, fork bombs, mkfs on device, dd to raw disk, /etc/passwd overwrite, curl|sh, chmod -R 777 /, etc.). Shell-operator-aware glob matcher mirroring Claude Code's Bash(*) syntax. Restrictive default — empty policy means every non-hard- denied call falls to Ask. Persisted to mcp-policy.json in app_config_dir. Includes a PolicyClassifier scaffold (no-op) for a future v2.1 LLM-classifier hook. 1152 lines incl. ~100 unit + fuzz tests covering the matchers and lookalike negatives. mcp.rs — TileService now holds AppHandle + Arc<PendingActions> (oneshot registry keyed by uuid). New async dispatch_action helper runs the policy check, emits "mcp://request" for the frontend to handle, awaits a oneshot reply (30s timeout), then emits "mcp:// audit" with the outcome regardless. set_label tool wired through this path as the demo for PR-1b's dispatcher. commands.rs / lib.rs — new Tauri commands mcp_action_reply, mcp_policy_load, mcp_policy_save; PendingActions registered as managed state. McpPanel.tsx — refactored into Config / Audit / Policy tabs. AuditTab listens on mcp://audit, keeps a 200-entry ring with ok/denied/failed chips. PolicyTab edits the allow/ask/deny buckets (stacked vertically — three columns overflowed the panel) and shows the hard-deny rules read-only at the bottom with "Cannot be disabled" badges. Themed scrollbar on mcp-body to match xterm panes. Caveat: set_label calls from Claude will currently time out — the App.tsx side that listens on mcp://request and replies via mcp_action_reply lands in PR-1b. Co-authored by Sonnet (policy engine, backend plumbing, panel UI) and Haiku (hard-deny fuzz test suite); integration + bug fixes here.
This commit is contained in:
parent
b14b450577
commit
464c576b79
11 changed files with 2512 additions and 144 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -4232,6 +4232,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"portable-pty",
|
||||
"rand 0.9.4",
|
||||
"regex",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ tower = "0.5"
|
|||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
rand = "0.9"
|
||||
hex = "0.4"
|
||||
regex = "1"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ use tokio::sync::RwLock;
|
|||
|
||||
use crate::creds;
|
||||
use crate::hosts::{self, SshHost, SshHostView};
|
||||
use crate::mcp::{self, McpMirror, McpServerHandle, McpState, RunningServer};
|
||||
use crate::mcp::{self, McpMirror, McpServerHandle, McpState, PendingActions, RunningServer};
|
||||
use crate::mcp_policy::McpPolicy;
|
||||
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
|
||||
|
||||
const WORKSPACE_FILE: &str = "workspace.json";
|
||||
|
|
@ -173,6 +174,7 @@ pub async fn mcp_start(
|
|||
ptys: tauri::State<'_, Arc<PtyManager>>,
|
||||
state: tauri::State<'_, Arc<RwLock<McpState>>>,
|
||||
handle: tauri::State<'_, McpServerHandle>,
|
||||
pending: tauri::State<'_, Arc<PendingActions>>,
|
||||
) -> Result<McpStatus, String> {
|
||||
{
|
||||
let g = handle.0.lock();
|
||||
|
|
@ -182,7 +184,8 @@ pub async fn mcp_start(
|
|||
}
|
||||
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
|
||||
let state_arc: Arc<RwLock<McpState>> = (*state).clone();
|
||||
let running: RunningServer = mcp::start_server(app, ptys_arc, state_arc)
|
||||
let pending_arc: Arc<PendingActions> = (*pending).clone();
|
||||
let running: RunningServer = mcp::start_server(app, ptys_arc, state_arc, pending_arc)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
{
|
||||
|
|
@ -209,6 +212,7 @@ pub async fn mcp_regenerate_token(
|
|||
ptys: tauri::State<'_, Arc<PtyManager>>,
|
||||
state: tauri::State<'_, Arc<RwLock<McpState>>>,
|
||||
handle: tauri::State<'_, McpServerHandle>,
|
||||
pending: tauri::State<'_, Arc<PendingActions>>,
|
||||
) -> Result<McpStatus, String> {
|
||||
let was_running = handle.0.lock().is_some();
|
||||
mcp::regenerate_token(&app).map_err(|e| e.to_string())?;
|
||||
|
|
@ -216,9 +220,11 @@ pub async fn mcp_regenerate_token(
|
|||
mcp::stop_server(&handle);
|
||||
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
|
||||
let state_arc: Arc<RwLock<McpState>> = (*state).clone();
|
||||
let running: RunningServer = mcp::start_server(app, ptys_arc, state_arc)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let pending_arc: Arc<PendingActions> = (*pending).clone();
|
||||
let running: RunningServer =
|
||||
mcp::start_server(app, ptys_arc, state_arc, pending_arc)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
*handle.0.lock() = Some(running);
|
||||
}
|
||||
Ok(server_status(&handle))
|
||||
|
|
@ -243,3 +249,47 @@ pub async fn mcp_update_state(
|
|||
g.mirror = mirror;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- MCP action-reply + policy commands ------------------------------------
|
||||
|
||||
/// Frontend calls this after handling an `mcp://request` event.
|
||||
/// `result` is JSON on success, an error string on failure/rejection.
|
||||
/// If `request_id` is unknown (stale or already timed out), this is a no-op
|
||||
/// — we log a warning and return Ok so the frontend doesn't see an error.
|
||||
#[tauri::command]
|
||||
pub async fn mcp_action_reply(
|
||||
pending: tauri::State<'_, Arc<PendingActions>>,
|
||||
request_id: String,
|
||||
result: Result<serde_json::Value, String>,
|
||||
) -> Result<(), String> {
|
||||
let sender = pending.0.lock().remove(&request_id);
|
||||
match sender {
|
||||
Some(tx) => {
|
||||
// If the receiver has already been dropped (e.g. timeout fired),
|
||||
// the send will fail — that's fine, just ignore it.
|
||||
let _ = tx.send(result);
|
||||
tracing::debug!(request_id = %request_id, "mcp_action_reply: sent");
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
request_id = %request_id,
|
||||
"mcp_action_reply: unknown request_id (stale or already timed out) — ignoring"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the current MCP policy. Returns the policy as a JSON-serialisable
|
||||
/// struct so the settings UI can display and edit it.
|
||||
#[tauri::command]
|
||||
pub async fn mcp_policy_load(app: AppHandle) -> Result<McpPolicy, String> {
|
||||
crate::mcp_policy::load_or_init(&app).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Persist an updated MCP policy. Validates structure by deserialising into
|
||||
/// McpPolicy before writing so a malformed payload can't corrupt the file.
|
||||
#[tauri::command]
|
||||
pub async fn mcp_policy_save(app: AppHandle, policy: McpPolicy) -> Result<(), String> {
|
||||
crate::mcp_policy::save(&app, &policy).map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ mod commands;
|
|||
mod creds;
|
||||
mod hosts;
|
||||
mod mcp;
|
||||
mod mcp_policy;
|
||||
mod pty;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::mcp::{McpServerHandle, McpState};
|
||||
use crate::mcp::{McpServerHandle, McpState, PendingActions};
|
||||
use crate::pty::PtyManager;
|
||||
|
||||
pub fn run() {
|
||||
|
|
@ -36,6 +37,9 @@ pub fn run() {
|
|||
let ptys: Arc<PtyManager> = Arc::new(PtyManager::new());
|
||||
let mcp_state: Arc<tokio::sync::RwLock<McpState>> =
|
||||
Arc::new(tokio::sync::RwLock::new(McpState::default()));
|
||||
// Pending action registry — separate managed state so mcp_action_reply can
|
||||
// grab it without needing to lock McpState or reach into TileService.
|
||||
let pending_actions: Arc<PendingActions> = Arc::new(PendingActions::default());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
|
|
@ -43,6 +47,7 @@ pub fn run() {
|
|||
.manage(ptys)
|
||||
.manage(mcp_state)
|
||||
.manage(McpServerHandle::default())
|
||||
.manage(pending_actions)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::list_distros,
|
||||
commands::spawn_pane,
|
||||
|
|
@ -61,6 +66,9 @@ pub fn run() {
|
|||
commands::mcp_status,
|
||||
commands::mcp_regenerate_token,
|
||||
commands::mcp_update_state,
|
||||
commands::mcp_action_reply,
|
||||
commands::mcp_policy_load,
|
||||
commands::mcp_policy_save,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use std::collections::HashMap;
|
|||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
|
|
@ -40,7 +40,7 @@ use rmcp::{
|
|||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
|
|
@ -169,6 +169,74 @@ pub struct McpState {
|
|||
pub mirror: McpMirror,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Action reply registry.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Registry of pending frontend action requests. Each entry maps a `requestId`
|
||||
/// to a oneshot sender that the `mcp_action_reply` Tauri command will fire
|
||||
/// once the frontend resolves or rejects the action.
|
||||
///
|
||||
/// Owned as separate managed state (Arc<PendingActions>) so Tauri commands can
|
||||
/// grab it via `tauri::State<'_, Arc<PendingActions>>` without needing to lock
|
||||
/// the entire McpState or pass TileService around.
|
||||
pub struct PendingActions(
|
||||
pub PlMutex<HashMap<String, tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>>>,
|
||||
);
|
||||
|
||||
impl Default for PendingActions {
|
||||
fn default() -> Self {
|
||||
Self(PlMutex::new(HashMap::new()))
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Audit / request event payload types.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpActionRequest {
|
||||
request_id: String,
|
||||
tool: &'static str,
|
||||
args: serde_json::Value,
|
||||
needs_confirm: bool,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
enum McpAuditResult {
|
||||
Ok,
|
||||
Denied { reason: String, hard: bool },
|
||||
Failed { msg: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpAuditEntry {
|
||||
ts_ms: u64,
|
||||
tool: &'static str,
|
||||
args_summary: String,
|
||||
result: McpAuditResult,
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn truncate_summary(s: &str) -> String {
|
||||
if s.len() > 80 {
|
||||
format!("{}...", &s[..80])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// MCP service: tools + resources.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
|
@ -177,6 +245,8 @@ pub struct McpState {
|
|||
pub struct TileService {
|
||||
ptys: Arc<PtyManager>,
|
||||
state: Arc<RwLock<McpState>>,
|
||||
pending: Arc<PendingActions>,
|
||||
app: AppHandle,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
|
|
@ -206,19 +276,222 @@ pub struct WaitForIdleArgs {
|
|||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct SetLabelArgs {
|
||||
/// 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,
|
||||
/// New human-readable label. Pass an empty string to clear the label.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
const READ_PANE_HARD_CAP_LINES: usize = 3000;
|
||||
const WAIT_TIMEOUT_HARD_CAP_MS: u64 = 5 * 60 * 1000;
|
||||
|
||||
#[tool_router]
|
||||
impl TileService {
|
||||
pub fn new(ptys: Arc<PtyManager>, state: Arc<RwLock<McpState>>) -> Self {
|
||||
pub fn new(
|
||||
ptys: Arc<PtyManager>,
|
||||
state: Arc<RwLock<McpState>>,
|
||||
pending: Arc<PendingActions>,
|
||||
app: AppHandle,
|
||||
) -> Self {
|
||||
Self {
|
||||
ptys,
|
||||
state,
|
||||
pending,
|
||||
app,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch an action that the frontend must execute. Generates a uuid,
|
||||
/// registers a oneshot, emits the "mcp://request" event with the args,
|
||||
/// awaits the reply with a 30s timeout. Also emits an "mcp://audit" event
|
||||
/// after the call resolves (regardless of outcome).
|
||||
async fn dispatch_action(
|
||||
&self,
|
||||
tool: &'static str,
|
||||
args: serde_json::Value,
|
||||
args_repr: String,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let start_ms = now_ms();
|
||||
let args_summary = truncate_summary(&args_repr);
|
||||
|
||||
tracing::debug!(tool, args_repr = %args_repr, "dispatch_action: start");
|
||||
|
||||
// 1. Load user policy.
|
||||
let policy = crate::mcp_policy::load_or_init(&self.app)
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
|
||||
// 2. Hard-deny check (for any tool — is_hard_denied checks for shell
|
||||
// catastrophe patterns; for non-write_pane tools the patterns are
|
||||
// unlikely to match args_repr but the check is cheap and safe).
|
||||
if let Some(label) = crate::mcp_policy::is_hard_denied(&args_repr) {
|
||||
let duration_ms = now_ms() - start_ms;
|
||||
let audit = McpAuditEntry {
|
||||
ts_ms: start_ms,
|
||||
tool,
|
||||
args_summary: args_summary.clone(),
|
||||
result: McpAuditResult::Denied {
|
||||
reason: label.to_string(),
|
||||
hard: true,
|
||||
},
|
||||
duration_ms,
|
||||
};
|
||||
tracing::debug!(tool, reason = label, hard = true, "dispatch_action: hard-denied");
|
||||
let _ = self.app.emit("mcp://audit", &audit);
|
||||
return Err(McpError::invalid_params(
|
||||
format!("hard-denied: {label}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Evaluate user-policy decision.
|
||||
let decision = crate::mcp_policy::evaluate(&policy, tool, &args_repr);
|
||||
|
||||
tracing::debug!(tool, ?decision, "dispatch_action: policy decision");
|
||||
|
||||
// 4. Handle Deny.
|
||||
let (needs_confirm, ask_reason) = match &decision {
|
||||
crate::mcp_policy::PolicyDecision::Allow => (false, None),
|
||||
crate::mcp_policy::PolicyDecision::Ask { reason } => {
|
||||
(true, Some(reason.clone()))
|
||||
}
|
||||
crate::mcp_policy::PolicyDecision::Deny { reason, hard } => {
|
||||
let duration_ms = now_ms() - start_ms;
|
||||
let audit = McpAuditEntry {
|
||||
ts_ms: start_ms,
|
||||
tool,
|
||||
args_summary: args_summary.clone(),
|
||||
result: McpAuditResult::Denied {
|
||||
reason: reason.clone(),
|
||||
hard: *hard,
|
||||
},
|
||||
duration_ms,
|
||||
};
|
||||
tracing::debug!(tool, reason = %reason, hard, "dispatch_action: denied by policy");
|
||||
let _ = self.app.emit("mcp://audit", &audit);
|
||||
return Err(McpError::invalid_params(
|
||||
format!("denied: {reason}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Generate a unique request id, register oneshot, emit mcp://request.
|
||||
// uuid crate is not in Cargo.toml; generate via rand (already a dep).
|
||||
// TODO: if uuid (v4 feature) is added to Cargo.toml, replace with:
|
||||
// let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let request_id = {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
// Format as a RFC-4122-style UUID v4 string for frontend interop.
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
format!(
|
||||
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||
bytes[0], bytes[1], bytes[2], bytes[3],
|
||||
bytes[4], bytes[5],
|
||||
bytes[6], bytes[7],
|
||||
bytes[8], bytes[9],
|
||||
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
|
||||
)
|
||||
};
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
{
|
||||
self.pending.0.lock().insert(request_id.clone(), tx);
|
||||
}
|
||||
|
||||
let payload = McpActionRequest {
|
||||
request_id: request_id.clone(),
|
||||
tool,
|
||||
args,
|
||||
needs_confirm,
|
||||
reason: ask_reason,
|
||||
};
|
||||
tracing::debug!(tool, request_id = %request_id, needs_confirm, "dispatch_action: emitting mcp://request");
|
||||
let _ = self.app.emit("mcp://request", &payload);
|
||||
|
||||
// 6. Await reply with 30s timeout.
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), rx).await;
|
||||
|
||||
let duration_ms = now_ms() - start_ms;
|
||||
|
||||
match result {
|
||||
Err(_elapsed) => {
|
||||
// Timeout — remove stale sender from registry.
|
||||
self.pending.0.lock().remove(&request_id);
|
||||
let audit = McpAuditEntry {
|
||||
ts_ms: start_ms,
|
||||
tool,
|
||||
args_summary,
|
||||
result: McpAuditResult::Failed {
|
||||
msg: "timeout".into(),
|
||||
},
|
||||
duration_ms,
|
||||
};
|
||||
tracing::debug!(tool, request_id = %request_id, "dispatch_action: timed out");
|
||||
let _ = self.app.emit("mcp://audit", &audit);
|
||||
Err(McpError::internal_error(
|
||||
"action timed out waiting for frontend response",
|
||||
Some(json!({ "requestId": request_id })),
|
||||
))
|
||||
}
|
||||
Ok(Err(_recv_err)) => {
|
||||
// Sender was dropped (shouldn't happen normally).
|
||||
let audit = McpAuditEntry {
|
||||
ts_ms: start_ms,
|
||||
tool,
|
||||
args_summary,
|
||||
result: McpAuditResult::Failed {
|
||||
msg: "channel closed".into(),
|
||||
},
|
||||
duration_ms,
|
||||
};
|
||||
tracing::debug!(tool, request_id = %request_id, "dispatch_action: channel closed");
|
||||
let _ = self.app.emit("mcp://audit", &audit);
|
||||
Err(McpError::internal_error(
|
||||
"action channel closed unexpectedly",
|
||||
Some(json!({ "requestId": request_id })),
|
||||
))
|
||||
}
|
||||
Ok(Ok(reply)) => {
|
||||
// 7. On reply: emit audit, propagate. Destructure with
|
||||
// ownership so the success payload and the error string
|
||||
// move out cleanly (avoids borrow-then-move on `reply`).
|
||||
let (audit_result, err, ok_payload) = match reply {
|
||||
Ok(v) => {
|
||||
tracing::debug!(tool, request_id = %request_id, "dispatch_action: reply ok");
|
||||
(McpAuditResult::Ok, None, Some(v))
|
||||
}
|
||||
Err(msg) => {
|
||||
tracing::debug!(tool, request_id = %request_id, error = %msg, "dispatch_action: reply error");
|
||||
(
|
||||
McpAuditResult::Failed { msg: msg.clone() },
|
||||
Some(McpError::internal_error(msg, None)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
let audit = McpAuditEntry {
|
||||
ts_ms: start_ms,
|
||||
tool,
|
||||
args_summary,
|
||||
result: audit_result,
|
||||
duration_ms,
|
||||
};
|
||||
let _ = self.app.emit("mcp://audit", &audit);
|
||||
match err {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(ok_payload.expect("ok branch always sets ok_payload")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a leaf_id → pane_id under the MCP-allow gate.
|
||||
async fn resolve_pane(&self, leaf_id: &str) -> Result<PaneId, McpError> {
|
||||
let st = self.state.read().await;
|
||||
|
|
@ -340,6 +613,41 @@ impl TileService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Set or clear the human-readable label on a pane. \
|
||||
Pass empty string to clear. The leaf must be MCP-allowed.")]
|
||||
async fn set_label(
|
||||
&self,
|
||||
Parameters(args): Parameters<SetLabelArgs>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
// Validate leaf exists in mirror + is visible to MCP (mcpAllow=true
|
||||
// is enforced by the frontend before mirroring, so presence here
|
||||
// implies the user has allowed it).
|
||||
let _leaf = self
|
||||
.state
|
||||
.read()
|
||||
.await
|
||||
.mirror
|
||||
.leaves
|
||||
.get(&args.leaf_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
McpError::invalid_params(
|
||||
"unknown leaf_id (not visible to MCP; user may need to allow it)",
|
||||
Some(json!({ "leaf_id": &args.leaf_id })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let args_repr = format!("leafId={} label={}", &args.leaf_id, &args.label);
|
||||
let args_json = json!({ "leafId": &args.leaf_id, "label": &args.label });
|
||||
|
||||
tracing::debug!(leaf_id = %args.leaf_id, label = %args.label, "set_label: dispatching");
|
||||
let _ = self
|
||||
.dispatch_action("set_label", args_json, args_repr)
|
||||
.await?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text("ok")]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
|
|
@ -495,6 +803,7 @@ pub async fn start_server(
|
|||
app_handle: AppHandle,
|
||||
ptys: Arc<PtyManager>,
|
||||
state: Arc<RwLock<McpState>>,
|
||||
pending: Arc<PendingActions>,
|
||||
) -> Result<RunningServer> {
|
||||
let cfg = load_or_init_config(&app_handle)?;
|
||||
let token = cfg.token.clone();
|
||||
|
|
@ -505,13 +814,24 @@ pub async fn start_server(
|
|||
// Fresh service per session; cheap because we share state via Arcs.
|
||||
let ptys_f = ptys.clone();
|
||||
let state_f = state.clone();
|
||||
let pending_f = pending.clone();
|
||||
// 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).
|
||||
let app_handle_for_service = app_handle.clone();
|
||||
// Disable rmcp's DNS-rebinding host allowlist. The default only permits
|
||||
// localhost / 127.0.0.1 / ::1; legitimate WSL clients connect via the
|
||||
// dynamic WSL gateway IP (172.x.x.1) which can't be in any static list.
|
||||
// Bearer-token auth on /mcp is the real gatekeeper, and we're not
|
||||
// running in a browser context where DNS rebinding is a concern.
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
move || Ok(TileService::new(ptys_f.clone(), state_f.clone())),
|
||||
move || {
|
||||
Ok(TileService::new(
|
||||
ptys_f.clone(),
|
||||
state_f.clone(),
|
||||
pending_f.clone(),
|
||||
app_handle_for_service.clone(),
|
||||
))
|
||||
},
|
||||
LocalSessionManager::default().into(),
|
||||
StreamableHttpServerConfig::default().disable_allowed_hosts(),
|
||||
);
|
||||
|
|
@ -580,3 +900,4 @@ pub fn regenerate_token(app: &AppHandle) -> Result<String> {
|
|||
save_config(app, &cfg)?;
|
||||
Ok(cfg.token)
|
||||
}
|
||||
|
||||
|
|
|
|||
1152
src-tauri/src/mcp_policy.rs
Normal file
1152
src-tauri/src/mcp_policy.rs
Normal file
File diff suppressed because it is too large
Load diff
136
src/components/AuditTab.tsx
Normal file
136
src/components/AuditTab.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { McpAuditEntry } from "../ipc";
|
||||
|
||||
const RING_CAP = 200;
|
||||
|
||||
function fmtTime(tsMs: number): string {
|
||||
const d = new Date(tsMs);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
interface ResultChipProps {
|
||||
result: McpAuditEntry["result"];
|
||||
}
|
||||
|
||||
function ResultChip({ result }: ResultChipProps) {
|
||||
if (result.kind === "ok") {
|
||||
return <span className="audit-chip audit-chip--ok">ok</span>;
|
||||
}
|
||||
if (result.kind === "denied") {
|
||||
return (
|
||||
<span className="audit-chip audit-chip--denied">
|
||||
denied{result.hard && <em> hard</em>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="audit-chip audit-chip--failed">failed</span>;
|
||||
}
|
||||
|
||||
function rowClass(result: McpAuditEntry["result"]): string {
|
||||
if (result.kind === "ok") return "audit-row audit-row--ok";
|
||||
if (result.kind === "denied") return "audit-row audit-row--denied";
|
||||
return "audit-row audit-row--failed";
|
||||
}
|
||||
|
||||
interface AuditTabProps {
|
||||
/** Called when there are unread entries (tab not active). */
|
||||
onUnread?: () => void;
|
||||
/** True when this tab is the currently visible tab — clears unread. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export default function AuditTab({ onUnread, active }: AuditTabProps) {
|
||||
const [entries, setEntries] = useState<McpAuditEntry[]>([]);
|
||||
const [unread, setUnread] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
void listen<McpAuditEntry>("mcp://audit", (e) => {
|
||||
setEntries((prev) => {
|
||||
const next = [e.payload, ...prev];
|
||||
return next.length > RING_CAP ? next.slice(0, RING_CAP) : next;
|
||||
});
|
||||
if (!active) {
|
||||
setUnread((n) => n + 1);
|
||||
onUnread?.();
|
||||
}
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [active, onUnread]);
|
||||
|
||||
// Clear unread badge when tab becomes active.
|
||||
useEffect(() => {
|
||||
if (active) setUnread(0);
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div className="audit-tab">
|
||||
<div className="audit-toolbar">
|
||||
{unread > 0 && !active && (
|
||||
<span className="audit-unread">{unread} new</span>
|
||||
)}
|
||||
<button
|
||||
className="audit-clear"
|
||||
onClick={() => setEntries([])}
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="audit-empty">No MCP tool calls yet.</p>
|
||||
) : (
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Tool</th>
|
||||
<th>Args</th>
|
||||
<th>Result</th>
|
||||
<th>ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e, i) => (
|
||||
// Index is fine as key here — entries are prepended and never
|
||||
// reordered; i=0 is always the newest.
|
||||
<tr key={i} className={rowClass(e.result)}>
|
||||
<td className="audit-cell--time">{fmtTime(e.tsMs)}</td>
|
||||
<td className="audit-cell--tool">{e.tool}</td>
|
||||
<td className="audit-cell--args" title={e.argsSummary}>
|
||||
{e.argsSummary}
|
||||
</td>
|
||||
<td className="audit-cell--result">
|
||||
<ResultChip result={e.result} />
|
||||
{e.result.kind === "failed" && (
|
||||
<span className="audit-errmsg" title={e.result.msg}>
|
||||
{" "}
|
||||
{e.result.msg}
|
||||
</span>
|
||||
)}
|
||||
{e.result.kind === "denied" && e.result.reason && (
|
||||
<span className="audit-errmsg" title={e.result.reason}>
|
||||
{" "}
|
||||
{e.result.reason}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="audit-cell--dur">{e.durationMs}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,12 +32,67 @@
|
|||
}
|
||||
.mcp-close:hover { background: #2a2a2a; color: #ddd; }
|
||||
|
||||
/* ---- Tab bar ------------------------------------------------------------ */
|
||||
|
||||
.mcp-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.mcp-tab {
|
||||
position: relative;
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
background: transparent;
|
||||
color: #777;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 7px 12px 5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.mcp-tab:hover { color: #bbb; }
|
||||
.mcp-tab--active {
|
||||
color: #cce6ff;
|
||||
border-bottom-color: #4488cc;
|
||||
}
|
||||
|
||||
/* Unread dot badge on the Audit tab */
|
||||
.mcp-tab-badge {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #d8a040;
|
||||
vertical-align: middle;
|
||||
margin-left: 5px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* ---- Body --------------------------------------------------------------- */
|
||||
|
||||
.mcp-body {
|
||||
padding: 14px 18px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #2a2a2a transparent;
|
||||
}
|
||||
.mcp-body::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.mcp-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.mcp-body::-webkit-scrollbar-thumb {
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
.mcp-body::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }
|
||||
.mcp-body::-webkit-scrollbar-corner { background: transparent; }
|
||||
|
||||
.mcp-blurb {
|
||||
color: #aaa;
|
||||
|
|
@ -189,3 +244,355 @@
|
|||
}
|
||||
.mcp-security strong { color: #d8a040; }
|
||||
.mcp-security em { color: #d88; font-style: normal; }
|
||||
|
||||
/* =========================================================================
|
||||
Audit tab
|
||||
========================================================================= */
|
||||
|
||||
.audit-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.audit-unread {
|
||||
font-size: 10px;
|
||||
color: #d8a040;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.audit-clear {
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
background: #222;
|
||||
color: #aac;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 3px;
|
||||
padding: 2px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.audit-clear:hover:not(:disabled) { background: #2a2a3a; color: #ccd; }
|
||||
.audit-clear:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.audit-empty {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
.audit-table th {
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #666;
|
||||
padding: 0 6px 4px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.audit-table td {
|
||||
padding: 2px 6px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid #1c1c1c;
|
||||
}
|
||||
|
||||
/* Row tinting */
|
||||
.audit-row--ok td { background: rgba(80, 200, 80, 0.04); }
|
||||
.audit-row--denied td { background: rgba(220, 60, 60, 0.06); }
|
||||
.audit-row--failed td { background: rgba(220, 140, 30, 0.06); }
|
||||
|
||||
.audit-cell--time {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.audit-cell--tool {
|
||||
color: #cce6ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audit-cell--args {
|
||||
color: #aaa;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audit-cell--result {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audit-errmsg {
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.audit-cell--dur {
|
||||
color: #777;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Result chips */
|
||||
.audit-chip {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.audit-chip--ok { background: #1a3a1a; color: #80e080; border: 1px solid #2a5a2a; }
|
||||
.audit-chip--denied { background: #3a1a1a; color: #e06060; border: 1px solid #5a2a2a; }
|
||||
.audit-chip--failed { background: #3a2a10; color: #d8a040; border: 1px solid #5a4a20; }
|
||||
.audit-chip--denied em { font-style: italic; color: #c04040; margin-left: 3px; }
|
||||
|
||||
/* =========================================================================
|
||||
Policy tab
|
||||
========================================================================= */
|
||||
|
||||
.policy-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.policy-loading {
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.policy-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.policy-hint {
|
||||
flex: 1 1 auto;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.policy-save-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.policy-save-error {
|
||||
color: #e06060;
|
||||
font-size: 10px;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.policy-save-btn {
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: #1a3a1a;
|
||||
color: #80e080;
|
||||
border: 1px solid #2a6a2a;
|
||||
border-radius: 3px;
|
||||
padding: 4px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.policy-save-btn:hover:not(:disabled) { background: #225a22; }
|
||||
.policy-save-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.policy-buckets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.policy-bucket {
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.policy-bucket--deny { border-color: #3a2020; }
|
||||
.policy-bucket--ask { border-color: #3a3020; }
|
||||
.policy-bucket--allow { border-color: #1a2a1a; }
|
||||
|
||||
.policy-bucket-header {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #888;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.policy-bucket--deny .policy-bucket-header { color: #c06060; }
|
||||
.policy-bucket--ask .policy-bucket-header { color: #c09040; }
|
||||
.policy-bucket--allow .policy-bucket-header { color: #60a060; }
|
||||
|
||||
.policy-rule-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.policy-rule-empty {
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.policy-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.policy-rule-text {
|
||||
flex: 1 1 auto;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.policy-rule-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 3px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.policy-rule-remove:hover { color: #e06060; background: #2a1a1a; }
|
||||
|
||||
.policy-add-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.policy-add-input {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: #ddd;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.policy-add-input:focus { border-color: #4488cc; }
|
||||
|
||||
.policy-add-btn {
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
background: #222;
|
||||
color: #aac;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 3px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.policy-add-btn:hover:not(:disabled) { background: #2a2a3a; color: #ccd; }
|
||||
.policy-add-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* Hard-deny section */
|
||||
.policy-hard-deny {
|
||||
background: #0e0e0e;
|
||||
border: 1px solid #222;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.policy-hard-deny-header {
|
||||
font-size: 10px;
|
||||
font-variant: small-caps;
|
||||
letter-spacing: 0.1em;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.policy-hard-deny-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.policy-hard-deny-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.policy-hard-deny-rule code {
|
||||
font-family: inherit;
|
||||
color: #888;
|
||||
background: #0c0c0c;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #1e1e1e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.policy-hard-deny-badge {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #555;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.policy-hard-deny-footnote {
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
writeText as clipboardWriteText,
|
||||
} from "@tauri-apps/plugin-clipboard-manager";
|
||||
import type { McpStatus } from "../ipc";
|
||||
import AuditTab from "./AuditTab";
|
||||
import PolicyTab from "./PolicyTab";
|
||||
import "./McpPanel.css";
|
||||
|
||||
interface McpPanelProps {
|
||||
|
|
@ -18,6 +20,8 @@ interface McpPanelProps {
|
|||
totalPaneCount: number;
|
||||
}
|
||||
|
||||
type TabId = "config" | "audit" | "policy";
|
||||
|
||||
export default function McpPanel({
|
||||
status,
|
||||
onStart,
|
||||
|
|
@ -30,6 +34,8 @@ export default function McpPanel({
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [revealToken, setRevealToken] = useState(false);
|
||||
const [regenBusy, setRegenBusy] = useState(false);
|
||||
const [tab, setTab] = useState<TabId>("config");
|
||||
const [auditUnread, setAuditUnread] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
|
|
@ -73,6 +79,11 @@ export default function McpPanel({
|
|||
}
|
||||
}, [regenBusy, status.running, onRegenerateToken]);
|
||||
|
||||
function switchTab(id: TabId) {
|
||||
setTab(id);
|
||||
if (id === "audit") setAuditUnread(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="backdrop" onClick={onClose} aria-label="Close" />
|
||||
|
|
@ -82,72 +93,103 @@ export default function McpPanel({
|
|||
<button className="mcp-close" onClick={onClose} aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="mcp-tabs" role="tablist">
|
||||
<button
|
||||
className={`mcp-tab${tab === "config" ? " mcp-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={tab === "config"}
|
||||
onClick={() => switchTab("config")}
|
||||
>
|
||||
Config
|
||||
</button>
|
||||
<button
|
||||
className={`mcp-tab${tab === "audit" ? " mcp-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={tab === "audit"}
|
||||
onClick={() => switchTab("audit")}
|
||||
>
|
||||
Audit
|
||||
{auditUnread && <span className="mcp-tab-badge" aria-label="new entries" />}
|
||||
</button>
|
||||
<button
|
||||
className={`mcp-tab${tab === "policy" ? " mcp-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={tab === "policy"}
|
||||
onClick={() => switchTab("policy")}
|
||||
>
|
||||
Policy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mcp-body">
|
||||
<p className="mcp-blurb">
|
||||
Lets a Claude session on the same machine inspect this workspace
|
||||
via Model Context Protocol — see which panes are running, read
|
||||
their scrollback, wait for commands to settle. Read-only in v1;
|
||||
Claude can't send keystrokes or reshape the layout yet.
|
||||
</p>
|
||||
|
||||
<div className="mcp-toggle-row">
|
||||
<button
|
||||
className={`mcp-toggle${status.running ? " on" : ""}`}
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="mcp-dot" />
|
||||
{status.running ? "Server: ON" : "Server: OFF"}
|
||||
</button>
|
||||
<span className="mcp-allow-count">
|
||||
{allowedPaneCount} of {totalPaneCount} pane
|
||||
{totalPaneCount === 1 ? "" : "s"} allow-listed
|
||||
{allowedPaneCount === 0 && status.running && (
|
||||
<span className="mcp-allow-warn">
|
||||
{" "}
|
||||
— Claude will see nothing until you toggle 🤖 on at least
|
||||
one pane.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status.running && status.url && status.token && (
|
||||
{tab === "config" && (
|
||||
<>
|
||||
<div className="mcp-field">
|
||||
<label>URL</label>
|
||||
<div className="mcp-field-row">
|
||||
<input readOnly value={status.url} onFocus={(e) => e.currentTarget.select()} />
|
||||
<button onClick={() => copy(status.url!)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mcp-field">
|
||||
<label>Bearer token</label>
|
||||
<div className="mcp-field-row">
|
||||
<input
|
||||
readOnly
|
||||
type={revealToken ? "text" : "password"}
|
||||
value={status.token}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button onClick={() => setRevealToken((r) => !r)}>
|
||||
{revealToken ? "Hide" : "Show"}
|
||||
</button>
|
||||
<button onClick={() => copy(status.token!)}>Copy</button>
|
||||
<button onClick={regenerate} disabled={regenBusy}>
|
||||
{regenBusy ? "…" : "Regenerate"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mcp-hint">
|
||||
URL + token persist across restarts — paste the snippet
|
||||
into your Claude config once. Regenerate if the token
|
||||
leaks.
|
||||
</p>
|
||||
<p className="mcp-blurb">
|
||||
Lets a Claude session on the same machine inspect this workspace
|
||||
via Model Context Protocol — see which panes are running, read
|
||||
their scrollback, wait for commands to settle. Read-only in v1;
|
||||
Claude can't send keystrokes or reshape the layout yet.
|
||||
</p>
|
||||
|
||||
<div className="mcp-toggle-row">
|
||||
<button
|
||||
className={`mcp-toggle${status.running ? " on" : ""}`}
|
||||
onClick={() => { void toggle(); }}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="mcp-dot" />
|
||||
{status.running ? "Server: ON" : "Server: OFF"}
|
||||
</button>
|
||||
<span className="mcp-allow-count">
|
||||
{allowedPaneCount} of {totalPaneCount} pane
|
||||
{totalPaneCount === 1 ? "" : "s"} allow-listed
|
||||
{allowedPaneCount === 0 && status.running && (
|
||||
<span className="mcp-allow-warn">
|
||||
{" "}
|
||||
— Claude will see nothing until you toggle 🤖 on at least
|
||||
one pane.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mcp-field">
|
||||
<label>Claude Code config snippet (.mcp.json)</label>
|
||||
<pre className="mcp-snippet">
|
||||
{status.running && status.url && status.token && (
|
||||
<>
|
||||
<div className="mcp-field">
|
||||
<label>URL</label>
|
||||
<div className="mcp-field-row">
|
||||
<input readOnly value={status.url} onFocus={(e) => e.currentTarget.select()} />
|
||||
<button onClick={() => copy(status.url!)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mcp-field">
|
||||
<label>Bearer token</label>
|
||||
<div className="mcp-field-row">
|
||||
<input
|
||||
readOnly
|
||||
type={revealToken ? "text" : "password"}
|
||||
value={status.token}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button onClick={() => setRevealToken((r) => !r)}>
|
||||
{revealToken ? "Hide" : "Show"}
|
||||
</button>
|
||||
<button onClick={() => copy(status.token!)}>Copy</button>
|
||||
<button onClick={() => { void regenerate(); }} disabled={regenBusy}>
|
||||
{regenBusy ? "…" : "Regenerate"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mcp-hint">
|
||||
URL + token persist across restarts — paste the snippet
|
||||
into your Claude config once. Regenerate if the token
|
||||
leaks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mcp-field">
|
||||
<label>Claude Code config snippet (.mcp.json)</label>
|
||||
<pre className="mcp-snippet">
|
||||
{`{
|
||||
"mcpServers": {
|
||||
"tiletopia": {
|
||||
|
|
@ -161,85 +203,96 @@ export default function McpPanel({
|
|||
}
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() =>
|
||||
copy(
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
tiletopia: {
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
status.url,
|
||||
"--allow-http",
|
||||
"--header",
|
||||
`Authorization: Bearer ${status.token}`,
|
||||
],
|
||||
</pre>
|
||||
<button
|
||||
onClick={() =>
|
||||
copy(
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
tiletopia: {
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
status.url,
|
||||
"--allow-http",
|
||||
"--header",
|
||||
`Authorization: Bearer ${status.token}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy config snippet
|
||||
</button>
|
||||
</div>
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy config snippet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mcp-tips">
|
||||
<strong>Why the shim?</strong> Claude Code's HTTP-MCP
|
||||
client tries OAuth discovery and ignores static{" "}
|
||||
<code>headers</code> auth (Anthropic issues #17152, #46879).
|
||||
The <code>mcp-remote</code> stdio shim transparently
|
||||
proxies the HTTP endpoint with the bearer header attached,
|
||||
which sidesteps the OAuth flow entirely. Other MCP
|
||||
clients that handle bearer auth correctly can connect
|
||||
directly to the URL above with the token in an{" "}
|
||||
<code>Authorization</code> header.
|
||||
<br />
|
||||
<br />
|
||||
<strong>WSL connectivity:</strong> the URL uses{" "}
|
||||
<code>127.0.0.1</code>; a Claude session running inside
|
||||
WSL needs to either swap that for the WSL gateway IP
|
||||
(<code>ip route show default | awk '{`{print $3}`}'</code>{" "}
|
||||
inside WSL — changes after each WSL restart), or enable
|
||||
mirrored networking (<code>networkingMode=mirrored</code>{" "}
|
||||
in <code>%UserProfile%\.wslconfig</code>, Win11 22H2+)
|
||||
so <code>127.0.0.1</code> in WSL routes to this host.
|
||||
You'll likely also need to allow the port through Windows
|
||||
Defender Firewall:{" "}
|
||||
<code>
|
||||
New-NetFirewallRule -DisplayName 'tiletopia MCP'
|
||||
-Direction Inbound -Action Allow -Protocol TCP
|
||||
-LocalPort {status.url.match(/:(\d+)\//)?.[1] ?? "47821"}{" "}
|
||||
-Profile Any
|
||||
</code>{" "}
|
||||
(elevated PowerShell).
|
||||
</div>
|
||||
<div className="mcp-tips">
|
||||
<strong>Why the shim?</strong> Claude Code's HTTP-MCP
|
||||
client tries OAuth discovery and ignores static{" "}
|
||||
<code>headers</code> auth (Anthropic issues #17152, #46879).
|
||||
The <code>mcp-remote</code> stdio shim transparently
|
||||
proxies the HTTP endpoint with the bearer header attached,
|
||||
which sidesteps the OAuth flow entirely. Other MCP
|
||||
clients that handle bearer auth correctly can connect
|
||||
directly to the URL above with the token in an{" "}
|
||||
<code>Authorization</code> header.
|
||||
<br />
|
||||
<br />
|
||||
<strong>WSL connectivity:</strong> the URL uses{" "}
|
||||
<code>127.0.0.1</code>; a Claude session running inside
|
||||
WSL needs to either swap that for the WSL gateway IP
|
||||
(<code>ip route show default | awk '{`{print $3}`}'</code>{" "}
|
||||
inside WSL — changes after each WSL restart), or enable
|
||||
mirrored networking (<code>networkingMode=mirrored</code>{" "}
|
||||
in <code>%UserProfile%\.wslconfig</code>, Win11 22H2+)
|
||||
so <code>127.0.0.1</code> in WSL routes to this host.
|
||||
You'll likely also need to allow the port through Windows
|
||||
Defender Firewall:{" "}
|
||||
<code>
|
||||
New-NetFirewallRule -DisplayName 'tiletopia MCP'
|
||||
-Direction Inbound -Action Allow -Protocol TCP
|
||||
-LocalPort {status.url.match(/:(\d+)\//)?.[1] ?? "47821"}{" "}
|
||||
-Profile Any
|
||||
</code>{" "}
|
||||
(elevated PowerShell).
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!status.running && (
|
||||
<p className="mcp-off-hint">
|
||||
Server is off — no port is open. Token is generated when you
|
||||
start. Each pane needs the 🤖 chip toggled on for Claude to
|
||||
see it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mcp-security">
|
||||
<strong>Security:</strong> bound to <code>0.0.0.0</code> so WSL
|
||||
distros and other machines on your LAN can reach it; bearer
|
||||
token is the only thing keeping them out. Treat MCP access as
|
||||
equivalent to terminal access — don't share the token, don't
|
||||
run the server on an untrusted network. Saved SSH passwords are{" "}
|
||||
<em>never</em> exposed through MCP.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!status.running && (
|
||||
<p className="mcp-off-hint">
|
||||
Server is off — no port is open. Token is generated when you
|
||||
start. Each pane needs the 🤖 chip toggled on for Claude to
|
||||
see it.
|
||||
</p>
|
||||
{tab === "audit" && (
|
||||
<AuditTab
|
||||
active={tab === "audit"}
|
||||
onUnread={() => setAuditUnread(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="mcp-security">
|
||||
<strong>Security:</strong> bound to <code>0.0.0.0</code> so WSL
|
||||
distros and other machines on your LAN can reach it; bearer
|
||||
token is the only thing keeping them out. Treat MCP access as
|
||||
equivalent to terminal access — don't share the token, don't
|
||||
run the server on an untrusted network. Saved SSH passwords are{" "}
|
||||
<em>never</em> exposed through MCP.
|
||||
</p>
|
||||
{tab === "policy" && <PolicyTab />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
198
src/components/PolicyTab.tsx
Normal file
198
src/components/PolicyTab.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
import { mcpPolicyLoad, mcpPolicySave, type McpPolicy } from "../ipc";
|
||||
|
||||
const HARD_DENY_LABELS = [
|
||||
"rm -rf /",
|
||||
"rm -rf ~",
|
||||
"rm -rf /*",
|
||||
"fork bomb",
|
||||
"mkfs on device",
|
||||
"dd to raw disk",
|
||||
"overwrite system auth file",
|
||||
"pipe to shell from network",
|
||||
"chmod -R 777 /",
|
||||
"find / -delete",
|
||||
];
|
||||
|
||||
type Bucket = "deny" | "ask" | "allow";
|
||||
|
||||
const BUCKET_LABELS: Record<Bucket, string> = {
|
||||
deny: "Deny: blocked outright",
|
||||
ask: "Ask: confirm in a modal",
|
||||
allow: "Silently run",
|
||||
};
|
||||
|
||||
interface RuleListProps {
|
||||
bucket: Bucket;
|
||||
rules: string[];
|
||||
onRemove: (bucket: Bucket, index: number) => void;
|
||||
onAdd: (bucket: Bucket, rule: string) => void;
|
||||
}
|
||||
|
||||
function RuleList({ bucket, rules, onRemove, onAdd }: RuleListProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleAdd() {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) return;
|
||||
onAdd(bucket, trimmed);
|
||||
setDraft("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") handleAdd();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`policy-bucket policy-bucket--${bucket}`}>
|
||||
<div className="policy-bucket-header">{BUCKET_LABELS[bucket]}</div>
|
||||
<ul className="policy-rule-list">
|
||||
{rules.length === 0 && (
|
||||
<li className="policy-rule-empty">—</li>
|
||||
)}
|
||||
{rules.map((r, i) => (
|
||||
<li key={i} className="policy-rule">
|
||||
<code className="policy-rule-text">{r}</code>
|
||||
<button
|
||||
className="policy-rule-remove"
|
||||
onClick={() => onRemove(bucket, i)}
|
||||
aria-label={`Remove rule ${r}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="policy-add-row">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="policy-add-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g. write_pane(git push *)"
|
||||
aria-label={`Add ${bucket} rule`}
|
||||
/>
|
||||
<button
|
||||
className="policy-add-btn"
|
||||
onClick={handleAdd}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PolicyTab() {
|
||||
const [policy, setPolicy] = useState<McpPolicy | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void mcpPolicyLoad().then(setPolicy);
|
||||
}, []);
|
||||
|
||||
function mutate(updater: (p: McpPolicy) => McpPolicy) {
|
||||
setPolicy((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = updater(prev);
|
||||
setDirty(true);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove(bucket: Bucket, index: number) {
|
||||
mutate((p) => ({
|
||||
...p,
|
||||
permissions: {
|
||||
...p.permissions,
|
||||
[bucket]: p.permissions[bucket].filter((_, i) => i !== index),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function handleAdd(bucket: Bucket, rule: string) {
|
||||
mutate((p) => ({
|
||||
...p,
|
||||
permissions: {
|
||||
...p.permissions,
|
||||
[bucket]: [...p.permissions[bucket], rule],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!policy || !dirty || saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await mcpPolicySave(policy);
|
||||
setDirty(false);
|
||||
} catch (e) {
|
||||
setSaveError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!policy) {
|
||||
return <p className="policy-loading">Loading policy…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="policy-tab">
|
||||
<div className="policy-toolbar">
|
||||
<p className="policy-hint">
|
||||
Empty policy = every MCP tool call asks for confirmation. Add rules
|
||||
to bypass the prompt for patterns you trust, or to block patterns
|
||||
outright.
|
||||
</p>
|
||||
<div className="policy-save-area">
|
||||
{saveError && (
|
||||
<span className="policy-save-error">{saveError}</span>
|
||||
)}
|
||||
<button
|
||||
className="policy-save-btn"
|
||||
onClick={() => { void handleSave(); }}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="policy-buckets">
|
||||
{(["deny", "ask", "allow"] as Bucket[]).map((bucket) => (
|
||||
<RuleList
|
||||
key={bucket}
|
||||
bucket={bucket}
|
||||
rules={policy.permissions[bucket]}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="policy-hard-deny">
|
||||
<div className="policy-hard-deny-header">Always blocked (built-in)</div>
|
||||
<ul className="policy-hard-deny-list">
|
||||
{HARD_DENY_LABELS.map((label) => (
|
||||
<li key={label} className="policy-hard-deny-rule">
|
||||
<code>{label}</code>
|
||||
<span className="policy-hard-deny-badge">Cannot be disabled</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="policy-hard-deny-footnote">
|
||||
These patterns are caught regardless of policy. Best-effort accident
|
||||
prevention, not a sandbox — see README.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/ipc.ts
41
src/ipc.ts
|
|
@ -134,3 +134,44 @@ export const mcpRegenerateToken = (): Promise<McpStatus> =>
|
|||
invoke("mcp_regenerate_token");
|
||||
export const mcpUpdateState = (mirror: McpMirror): Promise<void> =>
|
||||
invoke("mcp_update_state", { mirror });
|
||||
|
||||
// ---- MCP audit log (events) ---------------------------------------------
|
||||
|
||||
export interface McpAuditEntry {
|
||||
tsMs: number;
|
||||
tool: string;
|
||||
argsSummary: string; // already truncated to 80 chars by backend
|
||||
result:
|
||||
| { kind: "ok" }
|
||||
| { kind: "denied"; reason: string; hard: boolean }
|
||||
| { kind: "failed"; msg: string };
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface McpActionRequest {
|
||||
requestId: string;
|
||||
tool: string;
|
||||
args: unknown;
|
||||
needsConfirm: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
// ---- MCP policy ---------------------------------------------------------
|
||||
|
||||
export interface McpPolicy {
|
||||
version: number;
|
||||
permissions: {
|
||||
deny: string[];
|
||||
ask: string[];
|
||||
allow: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const mcpPolicyLoad = (): Promise<McpPolicy> =>
|
||||
invoke("mcp_policy_load");
|
||||
|
||||
export const mcpPolicySave = (policy: McpPolicy): Promise<void> =>
|
||||
invoke("mcp_policy_save", { policy });
|
||||
|
||||
// (No JS wrapper for mcp_action_reply or events — App.tsx wires those
|
||||
// directly in the integration step.)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue