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
198
src/components/PolicyTab.tsx
Normal file
198
src/components/PolicyTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue