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:
megaproxy 2026-05-26 16:04:14 +01:00
parent 71f330e934
commit 9ebb3e4d2e
8 changed files with 513 additions and 5 deletions

View file

@ -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}` };
}

View file

@ -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">

View file

@ -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;
};
}