Add opt-in per-NE CLI exec_policy for MCP/API exec.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-22 09:27:40 +08:00
parent bfac0b53c8
commit b9be545a75
20 changed files with 463 additions and 24 deletions

View file

@ -14,6 +14,7 @@ import {
buildManagedNeSaveBody,
emptyManagedNeForm,
formFromManagedNe,
isLinuxDeviceType,
type ManagedNeFormState,
} from "./formState";
@ -56,6 +57,7 @@ export function ManagedNeFormDialog({
}, [open, editingId]);
const vendors = metaQuery.data?.vendors ?? [];
const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled);
const deviceTypes = useMemo(() => {
const base = metaQuery.data?.device_types ?? [];
const cur = String(form.device_type || "").trim();
@ -70,6 +72,7 @@ export function ManagedNeFormDialog({
hopHostRequired: t("managedNe.hop.hostRequired"),
hopUserRequired: t("managedNe.hop.userRequired"),
hopPasswordRequired: t("managedNe.hop.passwordRequired"),
execPolicyEnabled: Boolean(metaQuery.data?.exec_policy_enabled),
});
if (editing) {
return updateManagedNe(editing.id, body);
@ -131,7 +134,14 @@ export function ManagedNeFormDialog({
label={t("managedNe.col.deviceType")}
required
value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
onChange={(e) => {
const device_type = e.target.value;
setForm((prev) => ({
...prev,
device_type,
exec_policy: isLinuxDeviceType(device_type) ? prev.exec_policy : "readonly",
}));
}}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
@ -139,6 +149,29 @@ export function ManagedNeFormDialog({
</option>
))}
</FieldSelect>
{execPolicyEnabled ? (
<>
<FieldSelect
label={t("managedNe.col.execPolicy")}
value={form.exec_policy}
onChange={(e) =>
setForm({
...form,
exec_policy: e.target.value as ManagedNeFormState["exec_policy"],
})
}
>
<option value="readonly">{t("managedNe.execPolicy.readonly")}</option>
{isLinuxDeviceType(form.device_type) ? (
<>
<option value="linux_shell">{t("managedNe.execPolicy.linuxShell")}</option>
<option value="unrestricted">{t("managedNe.execPolicy.unrestricted")}</option>
</>
) : null}
</FieldSelect>
<p className="form-field-hint form-grid__full">{t("managedNe.execPolicy.hint")}</p>
</>
) : null}
<TextField
fullWidth
isRequired

View file

@ -27,6 +27,7 @@ export type ManagedNeFormState = {
hop_vrf: string;
hop_target_auth_mode: "bastion_managed" | "manual";
hop_enter_system_view: boolean;
exec_policy: "readonly" | "linux_shell" | "unrestricted";
};
export function deviceTypeForVendor(vendor: string): string {
@ -38,6 +39,13 @@ export function deviceTypeForVendor(vendor: string): string {
return "generic";
}
export function isLinuxDeviceType(deviceType: string | undefined | null): boolean {
const low = String(deviceType || "")
.trim()
.toLowerCase();
return low === "linux" || low === "linux_ssh" || low === "linux_telnet" || low.startsWith("linux_");
}
export function emptyManagedNeForm(): ManagedNeFormState {
return {
name: "",
@ -61,6 +69,7 @@ export function emptyManagedNeForm(): ManagedNeFormState {
hop_vrf: "",
hop_target_auth_mode: "bastion_managed",
hop_enter_system_view: false,
exec_policy: "readonly",
};
}
@ -112,6 +121,10 @@ export function formFromManagedNe(row: ManagedNeItem): ManagedNeFormState {
hop_vrf: row.hop_vrf,
hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
hop_enter_system_view: Boolean(row.hop_enter_system_view),
exec_policy:
row.exec_policy === "linux_shell" || row.exec_policy === "unrestricted"
? row.exec_policy
: "readonly",
};
}
@ -127,7 +140,14 @@ export function managedSourceKey(
/** Build API body + hop validation. Throws Error with message key text already translated by caller. */
export function buildManagedNeSaveBody(
form: ManagedNeFormState,
opts: { editing: boolean; hopHostRequired: string; hopUserRequired: string; hopPasswordRequired: string },
opts: {
editing: boolean;
hopHostRequired: string;
hopUserRequired: string;
hopPasswordRequired: string;
/** When false, omit exec_policy so lab-open cannot be persisted accidentally. */
execPolicyEnabled?: boolean;
},
): Record<string, unknown> {
const body: Record<string, unknown> = {
name: form.name,
@ -152,6 +172,9 @@ export function buildManagedNeSaveBody(
...(form.password ? { password: form.password } : {}),
...(form.hop_password ? { hop_password: form.hop_password } : {}),
};
if (opts.execPolicyEnabled) {
body.exec_policy = isLinuxDeviceType(form.device_type) ? form.exec_policy : "readonly";
}
if (form.hop_enabled) {
if (!form.hop_host.trim()) throw new Error(opts.hopHostRequired);
if (!form.hop_username.trim()) throw new Error(opts.hopUserRequired);

View file

@ -495,6 +495,7 @@ export const fetchManagedNeMeta = () =>
device_types: types.device_types,
vendors: types.vendors,
credentials_configured: creds.configured,
exec_policy_enabled: Boolean(types.exec_policy_enabled),
}));
export type ManagedNeStats = {

View file

@ -211,6 +211,8 @@ export type ManagedNeItem = {
hop_vrf: string;
hop_target_auth_mode: string;
hop_enter_system_view?: boolean;
/** MCP/API CLI gate: readonly | linux_shell | unrestricted */
exec_policy?: "readonly" | "linux_shell" | "unrestricted";
created_at: string;
updated_at: string;
};
@ -225,6 +227,8 @@ export type ManagedNeListResponse = {
export type ManagedNeMeta = {
device_types: string[];
vendors: string[];
/** When false (default), exec_policy UI/API open policies are disabled. */
exec_policy_enabled?: boolean;
};
export type ManagedNeImportResult = {