首页
/ LobeHub 内置工具 Intervention 机制详解:执行前审批、参数编辑与本地系统工具的路径安全审计

LobeHub 内置工具 Intervention 机制详解:执行前审批、参数编辑与本地系统工具的路径安全审计

2026-09-05 12:54:30作者:尤辰城Agatha

本文以 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 注册表 所覆盖的 runCommandwriteFileeditFilemoveFiles 等 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.approvalModeauto-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 可带 reasoncancel 表示放弃整个 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 这类"看一眼就批准"的场景就不需要编辑能力;而 EditLocalFileWriteFile 等组件则提供参数修改入口。

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-runtimeresolvePathWithScope / normalizePathForScope,相对路径先相对 effectiveScope 解析再比较,避免 ../ 绕过;
  • extractPaths 覆盖多种参数形态:单路径参数、glob/grep 的绝对 patternmoveFilesitems[].oldPath/newPath 数组,保证移动类操作的新旧路径都被审计;
  • /tmp/var/tmp 视为安全前缀,可通过 areAllPathsSafe 扩展自定义安全判定;
  • 测试用例 interventionAudit.test.ts 对该行为有回归覆盖,模块经 src/index.ts 导出 createPathScopeAuditpathScopeAudit

配合第二节提到的 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 无人值守"与"敏感操作有人把关"解耦成三层:

  1. 策略层(manifest 声明):never / required / always、参数级规则、dynamic 审计、用户 approvalMode 与安全黑名单共同决定"是否干预";
  2. 渲染层BuiltinInterventionProps 契约):组件负责参数预览与可选的 Edit-Before-Run,通过 onArgsChangeonInteractionActionregisterBeforeApprove 三个回调与框架完成数据同步与竞态防护;
  3. 审计层interventionAudit):以 pathScopeAudit 为代表,在批准前做工作目录作用域校验,让"目录内自动执行、越界强制审批"成为声明式配置。

RunCommand 组件 为模板、以 LocalSystemInterventions 注册表 为组织范式,即可为任意新增的破坏性 API 补齐人工确认层,而不需要触碰工具执行器本身。

登录后查看全文
热门项目推荐
相关项目推荐