MCP v2 PR-4: add_host + delete_host + extraArgs sanitiser + third SSH safeguard
Final v2 PR. All 11 planned write tools live. add_host/delete_host let Claude mutate the saved-hosts list; both gated by a new allowAddHost switch (default off) — symmetric with the allowOpenSsh gate from PR-3.5. add_host's extraArgs are sanitised against CVE-2023-51385-class local-RCE primitives: ProxyCommand, LocalCommand, KnownHostsCommand, PermitLocalCommand=yes are refused server-side. Recognises both -o KEY=VAL and -oKEY=VAL, case-insensitive on the key. The manual host manager UI stays unrestricted (user has full agency over their own hosts). Also fixes a pre-existing compile bug: mcp_policy.rs's policy_with test helper was missing the ssh_safeguards field added in PR-3.5, silently breaking the entire policy test module since then. Re-enabling those tests is the prereq for the hard-deny rework that follows in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
71f330e934
commit
9ebb3e4d2e
8 changed files with 513 additions and 5 deletions
69
src/App.tsx
69
src/App.tsx
|
|
@ -1119,6 +1119,58 @@ export default function App() {
|
|||
summary: `Promote pane "${leaf.label ?? a.leafId.slice(0, 8)}" up one level`,
|
||||
};
|
||||
}
|
||||
case "add_host": {
|
||||
const a = args as {
|
||||
label?: string;
|
||||
hostname?: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
identityFile?: string;
|
||||
jumpHost?: string;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
if (typeof a.hostname !== "string" || !a.hostname.trim()) {
|
||||
throw new Error("missing hostname");
|
||||
}
|
||||
const hostname = a.hostname.trim();
|
||||
const label = (a.label && a.label.trim()) || hostname;
|
||||
const id = newId();
|
||||
const newHost: SshHost = {
|
||||
id,
|
||||
label,
|
||||
hostname,
|
||||
...(a.user ? { user: a.user } : {}),
|
||||
...(a.port ? { port: a.port } : {}),
|
||||
...(a.identityFile ? { identityFile: a.identityFile } : {}),
|
||||
...(a.jumpHost ? { jumpHost: a.jumpHost } : {}),
|
||||
...(a.extraArgs && a.extraArgs.length > 0
|
||||
? { extraArgs: a.extraArgs }
|
||||
: {}),
|
||||
};
|
||||
const next = [...hosts, newHost];
|
||||
setHosts(next);
|
||||
await saveSshHosts(next);
|
||||
return {
|
||||
payload: { hostId: id, label, hostname },
|
||||
summary: `Save SSH host "${label}" (${a.user ? `${a.user}@` : ""}${hostname}${a.port ? `:${a.port}` : ""})`,
|
||||
};
|
||||
}
|
||||
case "delete_host": {
|
||||
const a = args as { hostId?: string };
|
||||
if (typeof a.hostId !== "string") throw new Error("missing hostId");
|
||||
const host = hosts.find((h) => h.id === a.hostId);
|
||||
if (!host) throw new Error(`unknown host_id: ${a.hostId}`);
|
||||
const next = hosts.filter((h) => h.id !== a.hostId);
|
||||
setHosts(next);
|
||||
// save_ssh_hosts on the backend sweeps orphan keyring credentials
|
||||
// for any id that disappears from the list, so no separate
|
||||
// delete_host_password call is needed.
|
||||
await saveSshHosts(next);
|
||||
return {
|
||||
payload: { hostId: a.hostId, label: host.label },
|
||||
summary: `Delete SSH host "${host.label}" (${host.hostname})`,
|
||||
};
|
||||
}
|
||||
case "apply_preset": {
|
||||
const a = args as { name?: string; allowDrops?: boolean };
|
||||
const presetMap: Record<string, (d: Partial<LeafNode>) => TreeNode> = {
|
||||
|
|
@ -1256,6 +1308,23 @@ export default function App() {
|
|||
const suffix = a.allowDrops ? " (drops allowed)" : "";
|
||||
return { summary: `Reshape workspace to ${a.name}${suffix}` };
|
||||
}
|
||||
case "add_host": {
|
||||
const a = args as {
|
||||
label?: string;
|
||||
hostname?: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
};
|
||||
const label = (a.label && a.label.trim()) || a.hostname || "(host)";
|
||||
const conn = `${a.user ? `${a.user}@` : ""}${a.hostname ?? ""}${a.port ? `:${a.port}` : ""}`;
|
||||
return { summary: `Save SSH host "${label}" (${conn})` };
|
||||
}
|
||||
case "delete_host": {
|
||||
const a = args as { hostId?: string };
|
||||
const host = a.hostId ? hosts.find((h) => h.id === a.hostId) : null;
|
||||
const name = host ? `"${host.label}" (${host.hostname})` : a.hostId;
|
||||
return { summary: `Delete SSH host ${name}` };
|
||||
}
|
||||
default:
|
||||
return { summary: `Run ${tool}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export default function PolicyTab() {
|
|||
}
|
||||
|
||||
function setSshSafeguard(
|
||||
key: "allowOpenSsh" | "autoAllowSpawnedSsh",
|
||||
key: "allowOpenSsh" | "autoAllowSpawnedSsh" | "allowAddHost",
|
||||
value: boolean,
|
||||
) {
|
||||
mutate((p) => ({
|
||||
|
|
@ -208,6 +208,24 @@ export default function PolicyTab() {
|
|||
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">
|
||||
|
|
|
|||
|
|
@ -165,11 +165,12 @@ export interface McpPolicy {
|
|||
ask: string[];
|
||||
allow: string[];
|
||||
};
|
||||
/** SSH-specific capability switches; mirrors Rust SshSafeguards. Both
|
||||
/** SSH-specific capability switches; mirrors Rust SshSafeguards. All
|
||||
* default to false on first load. */
|
||||
sshSafeguards: {
|
||||
allowOpenSsh: boolean;
|
||||
autoAllowSpawnedSsh: boolean;
|
||||
allowAddHost: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue