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>
254 lines
7.8 KiB
TypeScript
254 lines
7.8 KiB
TypeScript
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>
|
||
);
|
||
}
|