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:
megaproxy 2026-05-26 12:05:31 +01:00
parent b14b450577
commit 464c576b79
11 changed files with 2512 additions and 144 deletions

1
src-tauri/Cargo.lock generated
View file

@ -4232,6 +4232,7 @@ dependencies = [
"parking_lot",
"portable-pty",
"rand 0.9.4",
"regex",
"rmcp",
"schemars 1.2.1",
"serde",

View file

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

View file

@ -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())
}

View file

@ -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");

View file

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

File diff suppressed because it is too large Load diff

136
src/components/AuditTab.tsx Normal file
View 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>
);
}

View file

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

View file

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

View 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>
);
}

View file

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