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
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue