LobeHub 内置工具 Intervention 机制详解:执行前审批、参数编辑与本地系统工具的路径安全审计
本文以 LobeHub 仓库中的 Intervention(人工干预)UI 参考文档为主体,系统讲解 LobeHub 内置工具在"执行器真正跑起来之前"拦截工具调用、向用户展示参数预览、支持"先编辑再批准"(Edit-Before-Run)的完整机制。读完你可以掌握:如何在工具 manifest 中声明 humanIntervention 策略、如何按 BuiltinInterventionProps<Args> 契约实现一个 Intervention 组件、如何通过 registerBeforeApprove 处理防抖编辑状态、如何用 interventionAudit 做路径作用域校验,以及 Intervention 注册表(registry)的组织方式——这些正是为 shell 命令、文件写入、文件移动等破坏性操作加上人工确认层所需的全部实操细节。
一、Intervention 的生命周期:先展示,后执行
Intervention 是 LobeHub 内置工具(builtin tool)框架提供的可选人工确认层。它的生命周期非常明确:在 executor 执行之前渲染。当某个 API 的 manifest 设置了 humanIntervention 字段时,框架不会直接执行该 API,而是先渲染对应的 Intervention 组件:
- 用户先看到一份参数预览(例如即将执行的 shell 命令、要写入的文件路径与内容);
- 用户可以选择编辑参数(Edit-Before-Run),然后再批准;
- 用户也可以选择跳过(skip,可带原因)或取消(cancel,中止整个 turn)。
参考文档明确给出的适用场景是破坏性或敏感操作:shell 命令、文件写入、文件移动、支付、消息广播等。这正对应仓库中 local-system 工具的 Intervention 注册表 所覆盖的 runCommand、writeFile、editFile、moveFiles 等 API。
1.1 manifest 中的 humanIntervention 声明
humanIntervention 声明在工具的 manifest(BuiltinToolManifest.api[])中,是框架专用配置:模型可见的 tool schema 转换只读取 name/description/parameters,因此该字段永远不会泄漏到面向 LLM 的工具定义里。其类型是 ExtendedHumanInterventionConfig,定义见 packages/types/src/tool/builtin.ts:
// 三种基础策略(packages/types/src/tool/intervention.ts)
export type HumanInterventionPolicy =
| 'never' // 永不干预,自动执行
| 'required' // 需要干预(可被用户的 auto-run 模式绕过)
| 'always'; // 始终需要干预(auto-run 也无法绕过)
// 完整配置 = 简单策略 | 规则数组 | 动态求值
export type HumanInterventionConfig =
| HumanInterventionPolicy
| HumanInterventionRule[];
export type ExtendedHumanInterventionConfig =
| HumanInterventionConfig
| { dynamic: DynamicInterventionConfig };
HumanInterventionRule 支持参数级匹配:match 以参数名为键,值为 ArgumentMatcher(支持通配符前缀、exact/prefix/wildcard/regex 四种匹配类型),规则命中时应用对应 policy。例如:
humanIntervention: [
{ match: { command: 'ls:*' }, policy: 'never' }, // 只读命令放行
{ policy: 'always' }, // 兜底:其余一律要求审批
],
dynamic 形态则把一个 type 标识符交给运行时注册表(dynamicInterventionAudits)查找 resolver,由 (toolArgs, metadata) => Promise<boolean> 动态决定是否干预,default 字段指定 resolver 缺失或未命中时的兜底策略(默认 'never')。
此外,manifest 顶层还有一个工具级默认策略 humanIntervention?: HumanInterventionPolicy(默认 'never'),对所有未单独声明策略的 API 生效。用户侧还有全局 UserInterventionConfig.approvalMode(auto-run / allow-list / manual / headless)和安全黑名单 SecurityBlacklistConfig,它们与工具自身声明的策略共同决定"这一次调用到底要不要弹 Intervention",Intervention 组件只负责在"需要干预"被判定成立之后渲染确认 UI。
二、Props 契约:BuiltinInterventionProps<Args>
每个 Intervention 组件都接收统一的 Props 契约 BuiltinInterventionProps<Arguments>。参考文档给出的核心接口如下:
interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** 用户编辑参数时回调;approve 动作会 await 它 */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** approve / skip / cancel 时回调 */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** 注册"批准前必须先落盘"的回调,返回清理函数 */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
对照源码 packages/types/src/tool/builtin.ts,实际契约在文档基础上还包含两个字段,值得了解:
actionsPortalTarget?: HTMLElement | null:当宿主使用固定 footer(例如全局审批卡片)时,自定义 Intervention 应把操作区(submit / skip + 状态)portal 到该节点,使其固定在滚动内容下方而不是跟着滚动;字段缺省时组件把 footer 内联渲染。disabled?: boolean:在远程决议等待 producer ACK 期间,保持表单可见但不可交互。
三个回调是组件与框架之间协作的全部通道:
| 回调 | 触发时机 | 关键约定 |
|---|---|---|
onArgsChange |
用户编辑参数后 | 批准动作会等待该异步回调完成,因此它是"编辑后执行"的数据通路 |
onInteractionAction |
用户点批准/跳过/取消 | submit 携带 payload;skip 可带 reason;cancel 表示放弃整个 turn |
registerBeforeApprove |
组件挂载时注册 | 用于在批准前 flush 尚未提交的保存(如防抖写盘);必须返回清理函数 |
interactionMode 区分两种交互形态:'approval' 是标准的"预览 + 批准/跳过"二元卡片,'custom' 允许组件自带完整的表单交互。运行时还通过 classifyToolInterventionPresentation(见 packages/types/src/tool/intervention.ts)把待决干预归类为 tool_approval(binary) / question(form) / custom(form),避免"内联表单"和"二元审批卡片"仅因消息存储里同为 plugin.intervention.status = pending 而被混为一谈。
三、标准示例:RunCommand Intervention
参考文档给出的典范实现是 runCommand 的审批卡片。它的设计哲学是"展示预览,而不是表单":命令以高亮代码块呈现,超时时间以次要文本展示,用户一眼确认即可。
文档示例(节选自 packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx):
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
当前仓库中的实际实现(RunCommand/index.tsx)在文档示例之外补充了 formatTimeout 的完整定义,并微调了导入来源(Text 来自 @lobehub/ui/base-ui)与高亮块内边距,可以直接作为新组件的模板:
const formatTimeout = (ms?: number) => {
if (!ms) return null;
const seconds = ms / 1000;
if (seconds >= 60) return `${(seconds / 60).toFixed(1)}min`; // >= 60s 显示分钟
if (seconds >= 1) return `${seconds.toFixed(1)}s`; // >= 1s 显示秒
return `${ms}ms`; // < 1s 显示毫秒
};
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify={'space-between'}>
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type={'secondary'}>
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter
wrap
language={'sh'}
showLanguage={false}
style={{ padding: '4px 8px' }}
variant={'outlined'}
>
{command}
</Highlighter>
)}
</Flexbox>
);
});
可以看到,该组件只消费 args 做只读渲染——它没有传 onArgsChange,也就是一个纯"approval"预览,这符合后文第一条规则"默认给预览而非表单"。
四、Intervention 实现的四条规则
参考文档总结了四条必须遵守的实现规则,结合仓库源码逐条说明:
1. 默认展示预览,编辑是显式 opt-in。
编辑 UI 通过提供 onArgsChange 开启,通常以内联形式出现(如点击代码块进入编辑)。RunCommand 这类"看一眼就批准"的场景就不需要编辑能力;而 EditLocalFile、WriteFile 等组件则提供参数修改入口。
2. 有防抖编辑状态时,必须用 registerBeforeApprove 保证批准前落盘。
如果组件内部存在带防抖的文本编辑(用户正在打字、保存还没 flush),直接批准会用旧参数执行。正确做法是:
useEffect(() => {
const cleanup = registerBeforeApprove?.('flush-debounce', flushDebouncedSave);
return cleanup; // 必须返回清理函数
}, [registerBeforeApprove]);
框架的 approve 动作会先执行所有已注册的 before-approve 回调(await 其 Promise),再提交 onInteractionAction({ type: 'submit', ... }),从机制上消除了"编辑未落盘就执行"的竞态。
3. 三种结局对应三种 action。
- 用户批准 →
onInteractionAction({ type: 'submit', payload }); - 用户跳过本次调用(可附原因)→
{ type: 'skip', reason?: string }; - 用户取消整个 turn →
{ type: 'cancel' }。
4. 需要作用域/路径校验时,补一份 interventionAudit.ts。
当工具在批准之前需要校验路径是否越权,应在包根目录新增 interventionAudit 模块。local-system 的实现在 packages/builtin-tool-local-system/src/interventionAudit.ts,它正是 DynamicInterventionConfig.type 指向的动态审计 resolver。
五、interventionAudit:批准前的路径作用域校验
local-system 的动态审计回答的问题是:这次调用的目标路径是否落在工作目录之外? 落在外部(越权)返回 true,触发强制干预;落在内部返回 false,可自动执行。核心实现:
const SAFE_PATH_PREFIXES = ['/tmp', '/var/tmp'] as const;
export const createPathScopeAudit = (options: PathScopeAuditOptions = {}) => {
const { areAllPathsSafe } = options;
return async (toolArgs, metadata) => {
const workingDirectory = metadata?.workingDirectory as string | undefined;
const toolScope = toolArgs.scope as string | undefined;
if (!workingDirectory) return false;
// scope 本身越出工作目录 → 直接干预
if (toolScope && !isPathWithinWorkingDirectory(toolScope, workingDirectory, workingDirectory))
return true;
// 收集参数中所有路径:path / file_path / directory / oldPath / newPath,
// 以及以 '/' 开头的 pattern、items[].oldPath/newPath
const paths = extractPaths(toolArgs);
// 全部落在 /tmp、/var/tmp 且通过可选的 areAllPathsSafe 校验 → 放行
if (areAllPathsSafe && areAllPathsSafeCandidates(paths, effectiveScope)) {
if (await areAllPathsSafe({ paths, resolveAgainstScope: effectiveScope })) return false;
}
// 只要有任何一条路径越出工作目录 → 干预
return paths.some((p) => !isPathWithinWorkingDirectory(p, workingDirectory, effectiveScope));
};
};
export const pathScopeAudit = createPathScopeAudit();
实现要点:
- 路径解析复用
@lobechat/tool-runtime的resolvePathWithScope/normalizePathForScope,相对路径先相对effectiveScope解析再比较,避免../绕过; extractPaths覆盖多种参数形态:单路径参数、glob/grep 的绝对pattern、moveFiles的items[].oldPath/newPath数组,保证移动类操作的新旧路径都被审计;/tmp、/var/tmp视为安全前缀,可通过areAllPathsSafe扩展自定义安全判定;- 测试用例 interventionAudit.test.ts 对该行为有回归覆盖,模块经 src/index.ts 导出
createPathScopeAudit与pathScopeAudit。
配合第二节提到的 dynamic 配置,即可声明为"工作目录内自动执行、越界必须人工审批"的策略,而无需在 manifest 里写死规则。
六、Intervention 注册表:client/Intervention/index.ts
每个 builtin tool 包在 src/client/Intervention/index.ts 维护一张"API 名 → 组件"的映射表,框架执行前按 identifier/apiName 查表渲染。文档给出的骨架是:
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
当前仓库的实际注册表(Intervention/index.ts)覆盖 9 个 API,并体现了一个值得借鉴的工程实践——新旧 API 名双注册:
export const LocalSystemInterventions = {
// 新短名 API
[LocalSystemApiName.editFile]: EditLocalFile,
[LocalSystemApiName.globFiles]: GlobLocalFiles,
[LocalSystemApiName.grepContent]: GrepContent,
[LocalSystemApiName.listFiles]: ListLocalFiles,
[LocalSystemApiName.moveFiles]: MoveLocalFiles,
[LocalSystemApiName.readFile]: ReadLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.searchFiles]: SearchLocalFiles,
[LocalSystemApiName.writeFile]: WriteFile,
// 旧长名别名 —— 保证历史 DB 消息在 API 改名后仍能正常渲染
editLocalFile: EditLocalFile,
globLocalFiles: GlobLocalFiles,
/* … */
renameLocalFile: RenameLocalFile, // 旧 rename API 已并入 moveFiles,仅为历史消息保留
};
也就是说,注册表不仅是"需要审批的 API 清单",也是历史消息渲染的兼容层:数据库中存着旧 API 名的工具消息,靠遗留别名继续找到对应组件。目录内每个 API 一个子目录(EditLocalFile/、MoveLocalFiles/、RunCommand/ 等),另有一个共用的 OutOfScopeWarning.tsx 组件用于提示路径越界场景。
七、小结
LobeHub 的 Intervention 机制把"7×24 无人值守"与"敏感操作有人把关"解耦成三层:
- 策略层(manifest 声明):
never/required/always、参数级规则、dynamic审计、用户approvalMode与安全黑名单共同决定"是否干预"; - 渲染层(
BuiltinInterventionProps契约):组件负责参数预览与可选的 Edit-Before-Run,通过onArgsChange、onInteractionAction、registerBeforeApprove三个回调与框架完成数据同步与竞态防护; - 审计层(
interventionAudit):以pathScopeAudit为代表,在批准前做工作目录作用域校验,让"目录内自动执行、越界强制审批"成为声明式配置。
以 RunCommand 组件 为模板、以 LocalSystemInterventions 注册表 为组织范式,即可为任意新增的破坏性 API 补齐人工确认层,而不需要触碰工具执行器本身。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00