tiletopia/src/components/PolicyTab.tsx
megaproxy 5b970f8b48 Hard-deny: PowerShell patterns + drift-proof the label list
Four new compiled-in hard-deny rules covering PowerShell + cmd.exe
catastrophic patterns (mirror of the POSIX 10):

- Remove-Item / del / rd / ri / rm / erase / rmdir targeting C:\
  or user home / appdata
- Format-Volume / Clear-Disk with any flag (= an invocation, not a
  Get-Help lookup)
- iwr | iex pipe form (PowerShell web-to-execute)
- iex (irm ...) parenthesized form

Universal application — no shell-aware scoping yet. PS cmdlet
identifiers are distinctive enough that bash false-positives are
vanishingly unlikely. Shell-aware policy scoping remains a known
follow-up.

Drift-proof the "Always blocked" label list: backend now exposes
hard_deny_rules() via a new mcp_hard_deny_labels Tauri command, and
PolicyTab loads it at mount instead of hardcoding the list. Avoids
the 11→15 manual sync that would have been needed (and that had
already drifted twice this week).

cargo test --lib: 138 passed; 0 failed (118 prior + 20 new fuzz
cases for rules 11-14; hard_deny_rules_count bumped 10 → 14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:14:42 +01:00

254 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useRef } from "react";
import {
mcpHardDenyLabels,
mcpPolicyLoad,
mcpPolicySave,
type McpPolicy,
} from "../ipc";
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 [hardDenyLabels, setHardDenyLabels] = useState<string[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
void mcpPolicyLoad().then(setPolicy);
void mcpHardDenyLabels().then(setHardDenyLabels);
}, []);
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],
},
}));
}
function setSshSafeguard(
key: "allowOpenSsh" | "autoAllowSpawnedSsh" | "allowAddHost",
value: boolean,
) {
mutate((p) => ({
...p,
sshSafeguards: { ...p.sshSafeguards, [key]: value },
}));
}
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-ssh-safeguards">
<div className="policy-bucket-header">SSH safeguards</div>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.allowOpenSsh}
onChange={(e) => setSshSafeguard("allowOpenSsh", e.target.checked)}
/>
<div className="policy-toggle-text">
<strong>Allow Claude to open SSH connections.</strong> When off,
the <code>connect_host</code> and <code>spawn_pane(kind=ssh)</code>
{" "}tools refuse with a clear error. You can still open SSH
sessions manually via the titlebar 🔑 picker, and Claude can
interact with them if you toggle 🤖 on.
</div>
</label>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.autoAllowSpawnedSsh}
onChange={(e) =>
setSshSafeguard("autoAllowSpawnedSsh", e.target.checked)
}
disabled={!policy.sshSafeguards.allowOpenSsh}
/>
<div className="policy-toggle-text">
<strong>Auto-grant Claude access to newly-spawned SSH panes.</strong>{" "}
When off, an SSH pane Claude opens starts with 🤖 off you have
to explicitly toggle it before Claude can read scrollback or send
keystrokes. Only meaningful when the switch above is on.
</div>
</label>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.allowAddHost}
onChange={(e) =>
setSshSafeguard("allowAddHost", e.target.checked)
}
/>
<div className="policy-toggle-text">
<strong>Allow Claude to save or delete SSH hosts.</strong> When
off, the <code>add_host</code> and <code>delete_host</code> tools
refuse with a clear error only you manage the saved-hosts list
via the titlebar 🔑 picker. Extra ssh args (<code>-o ...</code>)
on saved hosts are still sanitised to reject command-execution
primitives (<code>ProxyCommand</code>, <code>LocalCommand</code>,
etc.) regardless of this switch.
</div>
</label>
</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">
{hardDenyLabels.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>
);
}