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

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