Warp 命令执行黑名单合并机制:Org 与用户 Denylist 的 Union 语义与逐行可编辑 UI 实现
Warp 命令执行黑名单合并机制:Org 与用户 Denylist 的 Union 语义与逐行可编辑 UI 实现
导读
在 Warp 的 Agent 模式(Agent Mode)中,command denylist(命令执行黑名单)决定了哪些命令 Agent 必须征得用户许可才能执行。本文基于 specs/REMOTE-1318 技术规范,深入讲解 Warp 如何将组织(Workspace/Org)下发的黑名单与用户执行配置文件(Execution Profile)中的个人黑名单合并为一个生效列表,并保证"组织条目不可删除、用户条目完全可编辑"的逐行差异化 UI。读完本文,你将掌握该功能的合并语义(Union + 去重)、权限解析核心 API、两处设置界面的渲染实现,以及对应的单元测试与手动验证方法。
背景:两类黑名单来源与"覆盖还是合并"的问题
在 Warp 的权限模型中,命令执行黑名单存在两个数据来源:
- 组织层(Org/Workspace override):由
AiAutonomySettings.execute_commands_denylist: Option<Vec<AgentModeCommandExecutionPredicate>>承载,是组织级强制设置,用户无法修改,对应源码见 workspace 模块 中的用户工作区自主设置。 - 用户层(Execution Profile):由
AIExecutionProfile.command_denylist: Vec<AgentModeCommandExecutionPredicate>承载,是用户在自己执行配置文件里维护的个人列表,定义于 execution_profiles/mod.rs。
改动之前的默认行为是:当组织设置了黑名单覆盖时,生效列表通过 unwrap_or_else 整体替换用户的个人列表——即"组织黑名单"一旦存在,用户自己添加的黑名单条目就全部失效。这与安全直觉相悖:用户希望的是"组织规定了底线,我还可以在此基础上更严格地约束 Agent",而不是"组织管了就啥都不能加"。
REMOTE-1318 的核心目标就是把这个"替换"语义改成"合并"语义:组织条目与用户条目取并集,去重后共同生效;同时把 UI 中"整段黑名单被禁用"改为"只有组织行禁用、用户行可编辑"。
生效列表的 Union 合并语义
合并逻辑落在权限解析核心 BlocklistAIPermissions::get_execute_commands_denylist_for_profile(),当前实现位于 app/src/ai/blocklist/permissions.rs:
pub fn get_execute_commands_denylist_for_profile(
&self,
profile_id: &ExecutionProfileId,
scope: &impl TeamScope,
ctx: &AppContext,
) -> Vec<AgentModeCommandExecutionPredicate> {
let autonomy_settings = Self::team_autonomy_settings(scope, ctx);
let profiles_model = AIExecutionProfilesModel::as_ref(ctx);
let user_denylist = profiles_model
.get_profile_by_id(profile_id, ctx)
.unwrap_or_else(|| profiles_model.default_profile(ctx))
.data()
.command_denylist
.clone();
match autonomy_settings.execute_commands_denylist {
Some(org_denylist) => {
let mut merged = org_denylist;
for item in user_denylist {
if !merged.contains(&item) {
merged.push(item);
}
}
merged
}
None => user_denylist,
}
}
这段代码精确实现了 TECH.md 中规定的伪代码语义,可归纳为以下行为不变量:
- Union 合并:组织覆盖存在时,生效列表 = 组织列表 + 用户列表中未重复的条目。
- 顺序:组织条目在前,用户条目追加在后。
- 去重:去重基于
AgentModeCommandExecutionPredicate的字符串表示(即正则文本本身)比较,用户条目若与组织条目正则相同则不再重复展示。 - 无覆盖时行为不变:
None分支直接返回用户个人列表,与旧行为完全一致。
值得注意的边界情况:当组织覆盖为 Some([])(空列表)时,match 仍走 Some 分支——组织已明确表态"不设任何组织条目",但用户的个人条目依然原样出现在合并结果中,且编辑器保持可用。空覆盖不等于"没有覆盖",这是规范中明确要求保留的语义(见 PRODUCT.md 不变量 15)。
此外还新增了 get_org_execute_commands_denylist(),app/src/ai/blocklist/permissions.rs,它只返回组织条目(无覆盖时返回空 Vec),供渲染层判断哪些行是"组织所属行":
pub fn get_org_execute_commands_denylist(
scope: &impl TeamScope,
ctx: &AppContext,
) -> Vec<AgentModeCommandExecutionPredicate> {
let autonomy_settings = Self::team_autonomy_settings(scope, ctx);
autonomy_settings
.execute_commands_denylist
.unwrap_or_default()
}
合并列表在命令执行时生效
合并结果不是只给 UI 看的摆设,它直接参与 Agent 命令执行的鉴权流程。get_execute_commands_denylist()(app/src/ai/blocklist/permissions.rs)会取出当前激活的 Execution Profile,再调用 get_execute_commands_denylist_for_profile() 拿到合并列表:
pub fn get_execute_commands_denylist(
&self,
terminal_view_id: Option<EntityId>,
scope: &impl TeamScope,
ctx: &AppContext,
) -> Vec<AgentModeCommandExecutionPredicate> {
let active_profile =
AIExecutionProfilesModel::as_ref(ctx).active_profile(terminal_view_id, ctx);
self.get_execute_commands_denylist_for_profile(active_profile.id(), scope, ctx)
}
由此,无论匹配条目来自组织还是用户,只要命令命中合并列表中任一正则,就会返回 CommandExecutionPermission::Denied(ExplicitlyDenylisted)。这一点在单元测试中得到了直接验证:permissions_tests.rs 中设置组织黑名单 "git .*" 与用户个人黑名单后,断言 can_autoexecute_command("git status", ...) 的结果为 ExplicitlyDenylisted,测试注释明确写道 "user denylist entries should be merged with org denylist, not replaced"(见 app/src/ai/blocklist/permissions_tests.rs)。
另外要注意:组织黑名单对所有 Execution Profile 一视同仁,不存在"某个 profile 绕过组织条目"的机制;合并逻辑发生在每个 profile 上,组织条目恒在其前部,天然具备跨 profile 的一致性(PRODUCT.md 不变量 4)。
编辑器始终可用:只要 AI 模式开启
旧实现中,一旦组织存在黑名单覆盖,整个黑名单编辑器(文本输入框)也会被禁用,用户完全没有新增条目的途径。本次改动拆除了这个限制:
AISettings::is_command_denylist_editable()移除了has_override_for_execute_commands_denylist()检查,现在只返回is_any_ai_enabled(app)(见 app/src/settings/ai.rs)。- 编辑器交互状态统一由
update_all_editor_interaction_states()管理(app/src/ai/execution_profiles/editor/mod.rs)。对比同函数中的 allowlist 与 directory allowlist 可以看到差异:这两者仍保留is_any_ai_enabled && !has_override_for_*的条件(组织覆盖时整体禁用),而黑名单编辑器只以is_any_ai_enabled为条件,组织覆盖不再影响其可用性。
Self::update_editor_interaction_state(
view.command_denylist_editor.as_ref(ctx).editor().clone(),
is_any_ai_enabled, // 黑名单:仅取决于 AI 是否开启
ctx,
);
Self::update_editor_interaction_state(
view.command_allowlist_editor.as_ref(ctx).editor().clone(),
is_any_ai_enabled
&& !ai_autonomy_settings.has_override_for_execute_commands_allowlist(),
ctx,
);
新增条目始终写入用户的 profile 黑名单(通过 AIExecutionProfilesModel::add_to_command_denylist),即使用户输入的组织条目重复,该副本仍会被持久化,只是不重复显示(PRODUCT.md 不变量 6、7)。
逐行禁用:从"整段禁用一个布尔值"到"每行独立状态"
改动前的 InputListItem 只有 { item, mouse_state_handle, on_remove_action } 三个字段,render_input_list() 接收一个全局 disabled: bool,导致整段列表要么全可编辑、要么全禁用。本次改动为每个条目引入独立状态(app/src/settings_view/settings_page.rs):
pub struct InputListItem<SettingsPageAction: Action + Clone> {
pub item: String,
pub mouse_state_handle: MouseStateHandle,
pub on_remove_action: SettingsPageAction,
pub is_disabled: bool,
/// Must be pre-created (not inline during render) to preserve mouse tracking.
pub tooltip_mouse_state: Option<MouseStateHandle>,
}
其中 is_disabled 控制该行的移除(×)按钮是否禁用以及文本是否使用禁用色;tooltip_mouse_state 用于组织行的悬停提示。render_input_list() 的签名也相应更新(app/src/settings_view/settings_page.rs):
pub fn render_input_list<SettingsPageAction: Action + Clone>(
title: Option<&str>,
items: impl IntoIterator<Item = InputListItem<SettingsPageAction>>,
handle: Option<&ViewHandle<SubmittableTextInput>>,
appearance: &Appearance,
) -> Box<dyn Element>
Tooltip 文案使用常量 WORKSPACE_OVERRIDE_TOOLTIP_TEXT(app/src/settings_view/settings_page.rs):
"This option is enforced by your organization's settings and cannot be customized."
该常量正是 PRODUCT.md 不变量 9 所规定的提示文案。wrap_disabled_with_workspace_override_tooltip 保留在 ui_helpers.rs,但不再由 render_input_list 整体包裹,而是由调用方在每个组织行上单独应用。
两个渲染面的实现:Profile 编辑器与旧版设置页
命令黑名单在两个界面出现:执行配置文件编辑器(新 UI)与旧版 AI 设置页。二者共享同一套逐行合并渲染逻辑,但实现位置不同。
执行配置文件编辑器(editor/mod.rs + ui_helpers.rs)
ExecutionProfileEditorView 新增了 command_denylist_tooltip_mouse_state_handles: Vec<MouseStateHandle> 字段(app/src/ai/execution_profiles/editor/mod.rs),与既有的 command_denylist_mouse_state_handles 并列:前者按合并列表大小创建、只被组织行消耗;后者按每行数量创建、用于行内移除按钮。
渲染函数 render_command_denylist_section()(app/src/ai/execution_profiles/editor/ui_helpers.rs)不再走 render_list_section 的整段工具条路径,而是直接构造 InputListItem 列表:
let ai_disabled = !AISettings::as_ref(app).is_any_ai_enabled(app);
let scope = view.team_context(app);
let org_denylist = BlocklistAIPermissions::get_org_execute_commands_denylist(&scope, app);
let mut tooltip_idx = 0usize;
let input_items: Vec<InputListItem<ExecutionProfileEditorViewAction>> = profile_data
.command_denylist
.iter()
.cloned()
.zip(view.command_denylist_mouse_state_handles.iter().cloned())
.rev()
.map(|(predicate, mouse_state_handle)| {
let is_org = org_denylist.contains(&predicate);
let tooltip_mouse_state = if is_org {
let handle = view
.command_denylist_tooltip_mouse_state_handles
.get(tooltip_idx)
.cloned();
tooltip_idx += 1;
handle
} else {
None
};
InputListItem {
item: predicate.to_string(),
mouse_state_handle,
on_remove_action: ExecutionProfileEditorViewAction::RemoveFromCommandDenylist {
predicate,
},
is_disabled: is_org || ai_disabled,
tooltip_mouse_state,
}
})
.collect();
这段代码的关键点:
- 归属判断:
org_denylist.contains(&predicate)判定某行是否属于组织。由于get_org_execute_commands_denylist返回的是组织原始条目(未与用户合并),判断只对组织条目为真。 - 逐行禁用:
is_disabled = is_org || ai_disabled,即"组织行禁用,或 AI 整体未开启时全部禁用"。 - 逐行 Tooltip:组织行从
command_denylist_tooltip_mouse_state_handles按序取出悬停句柄,用户行则为None——由此实现"只有组织行悬停显示组织强制提示,用户行没有 Tooltip"(PRODUCT.md 不变量 9、10、11)。 - 编辑器始终渲染:
render_input_list无条件传入Some(&view.command_denylist_editor),不再受组织覆盖影响。
对比同文件的 render_list_section()(app/src/ai/execution_profiles/editor/ui_helpers.rs)可以看到旧模式:它接收单一 is_editable: bool,is_disabled: !is_editable 作用于所有行,并且当 !is_editable 时用 wrap_disabled_with_workspace_override_tooltip 把整段列表包进一个全局 Tooltip。这个模式依然保留,供 allowlist、directory allowlist、MCP 列表等"组织覆盖时整段禁用"的列表使用(PRODUCT.md 不变量 13),因此对既有调用方完全向后兼容。
旧版 AI 设置页(agent_profiles_page.rs)
旧版设置页的 render_command_denylist()(app/src/settings_view/agent_profiles_page.rs)采用同样的逐行策略:先通过 BlocklistAIPermissions::get_org_execute_commands_denylist(&scope, app) 取组织列表,再逐个构造 InputListItem,is_disabled: is_org || ai_disabled,组织行消耗独立的 command_denylist_tooltip_mouse_state_handles:
let is_org = org_denylist.contains(&cmd);
let tooltip_mouse_state = if is_org { /* 取 tooltip 句柄 */ } else { None };
InputListItem {
item: cmd.to_string(),
mouse_state_handle,
on_remove_action: AgentProfilesPageAction::RemoveFromProfileCommandDenylist(cmd),
is_disabled: is_org || ai_disabled,
tooltip_mouse_state,
}
该页面同样在 AISettingsChangedEvent::AgentModeCommandExecutionDenylist 事件处理器中按合并列表重建两组鼠标句柄,保持与列表长度同步(app/src/settings_view/agent_profiles_page.rs)。
兼容性:其余 render_input_list 调用方
render_input_list 的其余调用方必须保持行为不变,它们只需把原有的全局 disabled 逻辑平移到每行的 is_disabled 上即可,包括:
- 旧版设置页的 allowlist、directory allowlist、MCP 列表(agent_profiles_page.rs 内
render_command_allowlist等函数); - Profile 编辑器中通过
render_list_section派生的所有列表类型(ui_helpers.rs); - 环境配置命令列表(update_environment_form.rs)。
从 render_command_allowlist 的实现可见这一平移模式:let disabled = !ai_settings.is_command_allowlist_editable(&scope, app); 后对每个条目统一赋 is_disabled: disabled、tooltip_mouse_state: None。
运行时刷新:组织覆盖变更时的 UI 同步
组织覆盖可能在应用运行期间发生变化(例如用户切换工作区、组织调整策略)。Profile 编辑器通过订阅 UserWorkspacesEvent::TeamsChanged 事件来保持黑名单渲染与句柄同步(app/src/ai/execution_profiles/editor/mod.rs):
let workspace = UserWorkspaces::handle(ctx);
ctx.subscribe_to_model(&workspace, |me, _, event, ctx| {
if let UserWorkspacesEvent::TeamsChanged = event {
Self::update_all_editor_interaction_states(me, ctx);
me.update_mouse_state_handles(ctx);
ctx.notify();
}
});
update_mouse_state_handles() 会按合并后的黑名单长度重建 command_denylist_tooltip_mouse_state_handles,保证与渲染行数一致。这覆盖了一个关键动态场景(PRODUCT.md 不变量 12):当组织从其覆盖中移除某条用户也曾独立添加的条目后,用户副本成为该条目的唯一来源,下一次设置刷新时该行会从"组织行(禁用 + Tooltip)"自动转变为"用户行(可删除、无 Tooltip)"。
用户移除某条用户行后,行立即消失,剩余行的鼠标句柄也会随之重建以保持同步(PRODUCT.md 不变量 17)。
数据层的增删与去重
无论从哪个界面提交新条目,最终都落到 AIExecutionProfilesModel(app/src/ai/execution_profiles/profiles.rs):
pub fn add_to_command_denylist(
&mut self,
profile_id: &ExecutionProfileId,
predicate: &AgentModeCommandExecutionPredicate,
ctx: &mut ModelContext<Self>,
) {
self.edit_profile_internal(
profile_id,
|profile| {
if !profile.command_denylist.contains(predicate) {
profile.command_denylist.push(predicate.clone());
return true;
}
false
},
ctx,
);
// ... 发送 TelemetryEvent::AIExecutionProfileAddedToDenylist
}
pub fn remove_from_command_denylist(
&mut self,
profile_id: &ExecutionProfileId,
predicate: &AgentModeCommandExecutionPredicate,
ctx: &mut ModelContext<Self>,
) {
self.edit_profile_internal(
profile_id,
|profile| {
let original_len = profile.command_denylist.len();
profile.command_denylist.retain(|p| p != predicate);
profile.command_denylist.len() != original_len
},
ctx,
);
// ... 发送 TelemetryEvent::AIExecutionProfileRemovedFromDenylist
}
两个方法都对用户 profile 内的列表做本地去重(contains / retain),因此即使合并渲染时做了跨源去重,用户自己的存储层也不会出现同文本重复项。这些增删操作还会上报遥测事件,便于追踪自主设置调整。
测试与验证策略
单元测试(permissions_tests.rs)
规范要求在 app/src/ai/blocklist/permissions_tests.rs 中扩展测试,覆盖如下不变量,当前仓库已包含对应用例(如 test_merged_denylist_deduplication 等):
| 不变量 | 测试点 |
|---|---|
| 合并语义 | 组织覆盖存在时 get_execute_commands_denylist_for_profile 返回两者并集 |
| 去重 | 用户条目与组织条目相同(同正则字符串)时只出现一次 |
| 执行鉴权 | 合并列表用于 can_autoexecute_command 检查,命中即 ExplicitlyDenylisted |
| 跨 Profile | 组织黑名单对所有 Execution Profile 生效,无 per-profile 绕过 |
| 无覆盖 | None 时行为与旧版完全一致 |
| 空覆盖 | Some([]) 时用户条目仍被保留展示 |
| 新增 API | get_org_execute_commands_denylist 返回正确的组织条目 |
手动验证清单
按 PRODUCT.md 的行为设计,可依如下步骤验证:
- 在组织覆盖处于激活状态时:编辑器应可用,组织行显示禁用的 × 按钮与组织强制 Tooltip,用户行可正常删除;
- 无组织覆盖时:行为与改动前一致(全部为用户行);
- 添加一条与组织条目重复的用户条目:合并列表只显示一次(用户侧仍持久化);
- 删除一条用户条目:行立即消失,剩余行渲染与鼠标句柄保持正确。
提交前检查
按照项目规范,改动需通过 cargo fmt 与 cargo clippy 才能合入,这与 Warp 仓库 AGENTS.md 中描述的代码质量要求一致。
总结
REMOTE-1318 将 Warp 的命令执行黑名单从"组织覆盖即整体替换并禁用"的粗粒度模型,重构为"Union 合并 + 逐行可编辑"的细粒度模型:生效列表由组织条目(在前)与用户条目(在后)去重组成,并在命令执行鉴权、执行配置文件编辑器、旧版 AI 设置页三个层面保持一致;编辑器只要 AI 模式开启就始终可用,组织行以禁用态 + 组织强制 Tooltip 呈现,用户行保持完整编辑能力。该功能的设计文档见 specs/REMOTE-1318/TECH.md 与 specs/REMOTE-1318/PRODUCT.md,核心实现集中在 app/src/ai/blocklist/permissions.rs、app/src/ai/execution_profiles/editor/ 与 app/src/settings_view/agent_profiles_page.rs 中,感兴趣的读者可以沿着这些路径继续深入。