feat(core): 重构 Plan 模式工具,新增 allowedWritePaths 路径限制

Plan 工具重构:
- 移除 plan_mode_respond 工具
- 新增 ask_user_question 工具:向用户提问并获取回复
- 新增 enter_plan_mode 工具:进入计划模式
- 新增 exit_plan_mode 工具:退出计划模式

allowedWritePaths 功能:
- AgentFilePermission 新增 allowedWritePaths 字段
- permission-merger 添加 isPathInAllowedWritePaths 检查函数
- executor 在写入操作时检查路径限制
This commit is contained in:
2025-12-16 13:49:45 +08:00
parent f7b934a69e
commit cd0c2bdbfb
9 changed files with 525 additions and 149 deletions
+12 -1
View File
@@ -15,7 +15,7 @@ import type {
AgentExecutionResult,
ImageData,
} from './types.js';
import { checkBashPermission } from './permission-merger.js';
import { checkBashPermission, isPathInAllowedWritePaths } from './permission-merger.js';
import { getProviderRegistry } from '../provider/index.js';
/**
@@ -261,6 +261,17 @@ export class AgentExecutor {
if (action === 'deny') {
return { allowed: false, reason: `${operation} 操作被禁止` };
}
// 检查 allowedWritePaths 限制(仅对 write 操作)
if (operation === 'write' && filePermission.allowedWritePaths) {
const filePath = params.path as string;
if (filePath && !isPathInAllowedWritePaths(filePath, filePermission.allowedWritePaths)) {
return {
allowed: false,
reason: `写入路径不在允许列表中: ${filePath}。只能写入: ${filePermission.allowedWritePaths.join(', ')}`,
};
}
}
}
}
@@ -82,6 +82,9 @@ function mergeFilePermission(
global: AgentFilePermission | undefined,
agent: AgentFilePermission | undefined
): AgentFilePermission {
// allowedWritePaths: Agent 优先,否则用 global,不继承 system
const allowedWritePaths = agent?.allowedWritePaths ?? global?.allowedWritePaths;
return {
read: mergeAction(system?.read, global?.read, agent?.read),
write: mergeAction(system?.write, global?.write, agent?.write),
@@ -92,6 +95,7 @@ function mergeFilePermission(
global?.sensitivePaths,
agent?.sensitivePaths
),
allowedWritePaths,
};
}
@@ -219,3 +223,46 @@ export function checkFilePathPermission(
return null;
}
/**
* 展开路径中的 ~ 符号
*/
function expandTilde(targetPath: string): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
if (targetPath.startsWith('~/')) {
return targetPath.replace('~/', homeDir + '/');
}
if (targetPath === '~') {
return homeDir;
}
return targetPath;
}
/**
* 检查文件路径是否在允许的写入路径列表中
* @param filePath 要检查的文件路径
* @param allowedPaths 允许的路径列表(支持通配符和 ~)
* @returns true 表示允许,false 表示不允许
*/
export function isPathInAllowedWritePaths(
filePath: string,
allowedPaths: string[] | undefined
): boolean {
// 如果没有配置 allowedWritePaths,表示不限制
if (!allowedPaths || allowedPaths.length === 0) {
return true;
}
// 展开文件路径中的 ~
const expandedFilePath = expandTilde(filePath);
// 检查是否匹配任一允许的路径
for (const allowedPath of allowedPaths) {
const expandedAllowedPath = expandTilde(allowedPath);
if (matchRule(expandedFilePath, expandedAllowedPath)) {
return true;
}
}
return false;
}
+7
View File
@@ -35,6 +35,13 @@ export interface AgentFilePermission {
delete?: PermissionAction;
/** 敏感路径规则 */
sensitivePaths?: PermissionRule[];
/**
* 允许写入的路径列表(支持通配符和 ~ 展开)
* 当 write='allow' 时,如果设置此项则只允许写入这些路径
* 写入其他路径会被拒绝
* 示例: ['~/.ai-terminal-assistant/plan/*']
*/
allowedWritePaths?: string[];
}
/**
+9 -3
View File
@@ -60,7 +60,11 @@ import {
} from './checkpoint/index.js';
// Plan 工具
import { planModeRespondTool } from './plan/index.js';
import {
askUserQuestionTool,
enterPlanModeTool,
exitPlanModeTool,
} from './plan/index.js';
// 所有工具列表(用于注册)
const allToolsWithMetadata: ToolWithMetadata[] = [
@@ -116,8 +120,10 @@ const allToolsWithMetadata: ToolWithMetadata[] = [
checkpointDiffTool,
checkpointRestoreTool,
// Plan 工具 (deferLoading: true - 仅 Plan 模式)
planModeRespondTool,
// Plan 工具
enterPlanModeTool, // deferLoading: false - Act 模式可用
exitPlanModeTool, // deferLoading: true - 仅 Plan 模式
askUserQuestionTool, // deferLoading: false - 通用
];
// 注册所有工具到 registry
@@ -0,0 +1,227 @@
/**
* Ask User Question Tool
*
* 向用户提问以澄清需求或获取决策。
* 参考 Claude Code 的 AskUserQuestion 实现。
*
* 特性:
* - 支持 1-4 个问题
* - 每个问题支持 2-4 个选项
* - 支持单选/多选
* - 系统自动添加 "Other" 选项供自由输入
*/
import type { ToolWithMetadata } from '../types.js';
import type { ToolResult } from '../../types/index.js';
/**
* 选项定义
*/
export interface QuestionOption {
/** 选项标签(1-5个词) */
label: string;
/** 选项说明 */
description?: string;
}
/**
* 问题定义
*/
export interface Question {
/** 问题内容(应以问号结尾) */
question: string;
/** 简短标签(最多12字符) */
header?: string;
/** 选项列表(2-4个) */
options?: QuestionOption[];
/** 是否允许多选,默认 false */
multiSelect?: boolean;
}
/**
* 工具参数
*/
export interface AskUserQuestionParams {
questions: Question[];
}
/**
* 验证问题结构
*/
function validateQuestions(questions: Question[]): string | null {
if (!Array.isArray(questions) || questions.length === 0) {
return 'questions 必须是非空数组';
}
if (questions.length > 4) {
return '最多只能包含 4 个问题';
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q.question || typeof q.question !== 'string') {
return `问题 ${i + 1}: question 是必需的字符串`;
}
if (q.header && q.header.length > 12) {
return `问题 ${i + 1}: header 最多 12 个字符`;
}
if (q.options) {
if (!Array.isArray(q.options)) {
return `问题 ${i + 1}: options 必须是数组`;
}
if (q.options.length < 2 || q.options.length > 4) {
return `问题 ${i + 1}: options 必须包含 2-4 个选项`;
}
for (let j = 0; j < q.options.length; j++) {
const opt = q.options[j];
if (!opt.label || typeof opt.label !== 'string') {
return `问题 ${i + 1} 选项 ${j + 1}: label 是必需的字符串`;
}
}
}
}
return null;
}
/**
* 格式化问题为可读输出
*/
function formatQuestions(questions: Question[]): string {
const output: string[] = [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const questionNumber = questions.length > 1 ? `[${i + 1}/${questions.length}] ` : '';
const header = q.header ? `${q.header}` : '';
output.push(`${questionNumber}${header}${q.question}`);
if (q.options && q.options.length > 0) {
output.push('');
const selectHint = q.multiSelect ? '(可多选)' : '(单选)';
output.push(`选项 ${selectHint}:`);
q.options.forEach((opt, idx) => {
const optionKey = String.fromCharCode(65 + idx); // A, B, C, D
const description = opt.description ? ` - ${opt.description}` : '';
output.push(` ${optionKey}. ${opt.label}${description}`);
});
// 自动添加 Other 选项
const otherKey = String.fromCharCode(65 + q.options.length);
output.push(` ${otherKey}. Other - 自定义输入`);
}
if (i < questions.length - 1) {
output.push('');
output.push('---');
output.push('');
}
}
return output.join('\n');
}
/**
* Ask User Question 工具
*/
export const askUserQuestionTool: ToolWithMetadata = {
name: 'ask_user_question',
description: `向用户提问以澄清需求或获取决策。
使用场景:
- 需要用户在多个方案中做选择
- 需要澄清不明确的需求
- 需要用户确认关键决策
参数结构:
{
"questions": [
{
"question": "问题内容(以问号结尾)",
"header": "简短标签(最多12字符,可选)",
"options": [
{ "label": "选项1", "description": "说明(可选)" },
{ "label": "选项2", "description": "说明" }
],
"multiSelect": false
}
]
}
约束:
- questions: 1-4 个问题
- options: 每个问题 2-4 个选项(系统自动添加 "Other" 选项)
- header: 最多 12 字符
- label: 建议 1-5 个词
示例:
ask_user_question({
questions: [{
question: "您希望使用哪种状态管理方案?",
header: "状态管理",
options: [
{ label: "Redux", description: "成熟稳定,生态丰富" },
{ label: "Zustand", description: "轻量简洁,学习曲线低" },
{ label: "Jotai", description: "原子化状态,适合细粒度更新" }
],
multiSelect: false
}]
})`,
metadata: {
name: 'ask_user_question',
category: 'agent',
description: '向用户提问以澄清需求或获取决策',
keywords: ['ask', 'question', 'clarify', 'decision', '提问', '澄清', '决策', '选择'],
deferLoading: false, // 通用工具,始终可用
},
parameters: {
questions: {
type: 'array',
description: '问题列表(1-4 个问题)',
required: true,
},
},
async execute(params: Record<string, unknown>): Promise<ToolResult> {
const questions = params.questions as Question[];
// 验证参数
const validationError = validateQuestions(questions);
if (validationError) {
return {
success: false,
output: '',
error: validationError,
};
}
// 格式化输出
const formattedOutput = formatQuestions(questions);
return {
success: true,
output: formattedOutput,
metadata: {
type: 'ask_user_question',
questionCount: questions.length,
questions: questions.map((q, i) => ({
index: i,
header: q.header,
optionCount: q.options?.length ?? 0,
multiSelect: q.multiSelect ?? false,
})),
// 标记需要用户输入
requiresUserInput: true,
},
};
},
};
@@ -0,0 +1,82 @@
/**
* Enter Plan Mode Tool
*
* 进入计划模式的工具。
* 参考 Claude Code 的 EnterPlanMode 实现。
*
* 在开始非平凡的实现任务前主动使用,让用户在编写代码之前先批准方案。
*
* 使用场景:
* - 新功能实现
* - 多种可行方案需要选择
* - 代码修改影响范围较大
* - 架构决策
* - 多文件变更
* - 需求不明确需要探索
*
* 不适用场景:
* - 单行修复(错别字、明显 bug)
* - 用户给出了非常具体详细的指令
* - 纯研究/探索任务
*/
import type { ToolWithMetadata } from '../types.js';
import type { ToolResult } from '../../types/index.js';
/**
* Enter Plan Mode 工具
*/
export const enterPlanModeTool: ToolWithMetadata = {
name: 'enter_plan_mode',
description: `进入计划模式,在开始非平凡的实现任务前主动使用。
使用场景:
- 新功能实现:如 "添加登出按钮"、"实现用户认证"
- 多种可行方案:如 "给 API 添加缓存"Redis/内存/文件)
- 代码修改:如 "更新登录流程"、"重构组件"
- 架构决策:如 "添加实时更新功能"WebSocket/SSE/轮询)
- 多文件变更:如 "重构认证系统"
- 需求不明确:如 "让应用更快"、"修复 checkout 的 bug"
何时不使用:
- 单行修复(错别字、明显 bug、小调整)
- 用户给出了非常具体详细的指令
- 纯研究/探索任务(使用 Task 工具的 explore agent
计划模式中的限制:
进入后只能使用只读工具:
✅ read_file, list_directory, search_files, grep_content
✅ web_search, web_extract
✅ task(探索代理)
✅ ask_user_question
❌ write_file, edit_file, bash(执行命令)
完成计划后使用 exit_plan_mode 退出并提交方案供用户审批。`,
metadata: {
name: 'enter_plan_mode',
category: 'agent',
description: '进入计划模式,用于规划实现方案',
keywords: ['plan', 'mode', 'enter', '计划', '模式', '规划', '方案'],
deferLoading: false, // 始终可用
},
parameters: {
// 无参数
},
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
return {
success: true,
output: '已进入计划模式。现在可以使用只读工具探索代码库,完成后使用 exit_plan_mode 提交方案。',
metadata: {
type: 'enter_plan_mode',
// 标记模式切换
modeTransition: {
from: 'act',
to: 'plan',
},
},
};
},
};
@@ -0,0 +1,136 @@
/**
* Exit Plan Mode Tool
*
* 退出计划模式,将方案提交给用户审批。
* 参考 Claude Code 的 ExitPlanMode 实现。
*
* 使用条件:
* 1. 已将计划写入计划文件
* 2. 计划清晰无歧义
* 3. 如有多种方案或不明确的需求,先用 AskUserQuestion 澄清
*
* 可选参数:
* - launchSwarm: 是否启动多代理协作来实施方案
* - teammateCount: 协作代理的数量
*/
import type { ToolWithMetadata } from '../types.js';
import type { ToolResult } from '../../types/index.js';
/**
* 工具参数
*/
export interface ExitPlanModeParams {
/** 是否启动 swarm 来实施方案 */
launchSwarm?: boolean;
/** swarm 中的队友数量 */
teammateCount?: number;
}
/**
* Exit Plan Mode 工具
*/
export const exitPlanModeTool: ToolWithMetadata = {
name: 'exit_plan_mode',
description: `退出计划模式,将方案提交给用户审批。
使用条件(调用前必须满足):
1. 已完成方案设计
2. 计划清晰无歧义
3. 如有多种方案或不明确的需求,先用 ask_user_question 澄清
何时使用:
- 当任务需要规划实现步骤并编写代码时使用
- 纯研究/探索任务不要使用此工具
参数说明:
- launchSwarm (可选): 是否启动多代理协作来实施方案
- teammateCount (可选): 协作代理的数量(需要 launchSwarm=true
示例:
1. 简单退出(用户手动执行):
exit_plan_mode({})
2. 启动 swarm 协作实施:
exit_plan_mode({
launchSwarm: true,
teammateCount: 3
})`,
metadata: {
name: 'exit_plan_mode',
category: 'agent',
description: '退出计划模式,提交方案供用户审批',
keywords: ['plan', 'mode', 'exit', 'submit', '计划', '模式', '退出', '提交', '审批'],
deferLoading: true, // 仅 Plan 模式下可用
},
parameters: {
launchSwarm: {
type: 'boolean',
description: '是否启动多代理协作来实施方案',
required: false,
},
teammateCount: {
type: 'number',
description: '协作代理的数量(需要 launchSwarm=true',
required: false,
},
},
async execute(params: Record<string, unknown>): Promise<ToolResult> {
const launchSwarm = params.launchSwarm as boolean | undefined;
const teammateCount = params.teammateCount as number | undefined;
// 验证参数
if (teammateCount !== undefined) {
if (typeof teammateCount !== 'number' || teammateCount < 1) {
return {
success: false,
output: '',
error: 'teammateCount 必须是大于 0 的数字',
};
}
if (!launchSwarm) {
return {
success: false,
output: '',
error: '使用 teammateCount 时需要设置 launchSwarm: true',
};
}
}
// 构建输出
const output: string[] = ['计划模式已结束,方案已提交审批。'];
if (launchSwarm) {
const count = teammateCount ?? 2;
output.push('');
output.push(`配置: 启动 Swarm 协作模式,${count} 个代理将并行实施方案。`);
}
return {
success: true,
output: output.join('\n'),
metadata: {
type: 'exit_plan_mode',
// 标记模式切换
modeTransition: {
from: 'plan',
to: 'act',
},
// Swarm 配置
swarm: launchSwarm
? {
enabled: true,
teammateCount: teammateCount ?? 2,
}
: {
enabled: false,
},
// 标记需要用户审批
requiresApproval: true,
},
};
},
};
+5 -1
View File
@@ -4,4 +4,8 @@
* Plan 模式专用工具集合
*/
export { planModeRespondTool } from './plan_mode_respond.js';
export { askUserQuestionTool } from './ask_user_question.js';
export { enterPlanModeTool } from './enter_plan_mode.js';
export { exitPlanModeTool } from './exit_plan_mode.js';
export type { Question, QuestionOption, AskUserQuestionParams } from './ask_user_question.js';
export type { ExitPlanModeParams } from './exit_plan_mode.js';
@@ -1,144 +0,0 @@
/**
* Plan Mode Respond Tool
*
* Plan 模式专用响应工具,用于结构化输出计划和进度信息。
* 参考 OpenCode 的 plan_mode_respond 实现。
*
* 特性:
* - response: 输出计划内容
* - needs_more_exploration: 标记是否需要更多探索
* - task_progress: 报告任务进度 (0-100)
*/
import type { ToolWithMetadata } from '../types.js';
import type { ToolResult } from '../../types/index.js';
/**
* 生成进度条字符串
*/
function generateProgressBar(progress: number): string {
const total = 20;
const filled = Math.round((progress / 100) * total);
const empty = total - filled;
return '[' + '='.repeat(filled) + ' '.repeat(empty) + ']';
}
/**
* Plan Mode 响应工具
*/
export const planModeRespondTool: ToolWithMetadata = {
name: 'plan_mode_respond',
description: `Plan 模式专用响应工具。使用此工具输出结构化的计划和进度信息。
使用场景:
- 完成探索后输出实现计划
- 报告需要更多探索(设置 needs_more_exploration: true
- 更新任务进度
参数说明:
- response (必需): 计划内容、分析结果或回复
- needs_more_exploration (可选): 是否需要继续探索。设为 true 表示当前信息不足
- task_progress (可选): 任务完成进度 (0-100)。0=刚开始,50=进行中,100=完成
示例:
1. 输出初步分析:
plan_mode_respond({
response: "## 现状分析\\n代码结构如下...",
needs_more_exploration: true,
task_progress: 30
})
2. 输出最终计划:
plan_mode_respond({
response: "## 实现方案\\n### 步骤 1: ...",
needs_more_exploration: false,
task_progress: 100
})`,
metadata: {
name: 'plan_mode_respond',
category: 'agent',
description: 'Plan 模式结构化响应工具',
keywords: ['plan', 'respond', 'response', 'progress', '计划', '响应', '进度'],
deferLoading: true, // 仅在 Plan 模式下加载
},
parameters: {
response: {
type: 'string',
description: '计划内容、分析结果或回复',
required: true,
},
needs_more_exploration: {
type: 'boolean',
description: '是否需要更多探索。设为 true 表示当前信息不足,需要继续调研',
required: false,
},
task_progress: {
type: 'number',
description: '任务完成进度 (0-100)。0=刚开始,50=进行中,100=完成',
required: false,
},
},
async execute(params: Record<string, unknown>): Promise<ToolResult> {
const {
response,
needs_more_exploration = false,
task_progress,
} = params as {
response: string;
needs_more_exploration?: boolean;
task_progress?: number;
};
// 验证参数
if (!response || typeof response !== 'string') {
return {
success: false,
output: '',
error: 'response 参数是必需的,且必须是字符串',
};
}
if (task_progress !== undefined) {
if (typeof task_progress !== 'number' || task_progress < 0 || task_progress > 100) {
return {
success: false,
output: '',
error: 'task_progress 必须是 0-100 之间的数字',
};
}
}
// 构建结构化输出
const output: string[] = [];
// 添加进度信息
if (task_progress !== undefined) {
const progressBar = generateProgressBar(task_progress);
output.push(`[进度: ${task_progress}%] ${progressBar}`);
output.push('');
}
// 添加响应内容
output.push(response);
// 添加探索状态
if (needs_more_exploration) {
output.push('');
output.push('---');
output.push('[状态: 需要更多探索]');
}
return {
success: true,
output: output.join('\n'),
metadata: {
needs_more_exploration,
task_progress,
type: 'plan_response',
},
};
},
};