lcagent-cli 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,61 +1,308 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
+ import { applyPatch as applyUnifiedPatch, parsePatch } from 'diff';
3
4
  import { z } from 'zod';
5
+ import { applyMatches, findEditMatches, stripTrailingWhitespace } from './editUtils.js';
4
6
  const inputSchema = z.object({
5
7
  path: z.string().min(1).optional(),
6
8
  filePath: z.string().min(1).optional(),
7
9
  file_path: z.string().min(1).optional(),
10
+ patch: z.string().optional(),
11
+ diff: z.string().optional(),
12
+ unifiedDiff: z.string().optional(),
13
+ unified_diff: z.string().optional(),
8
14
  oldText: z.string().optional(),
9
15
  old_text: z.string().optional(),
10
16
  newText: z.string().optional(),
11
17
  new_text: z.string().optional(),
18
+ replaceAll: z.boolean().optional(),
19
+ replace_all: z.boolean().optional(),
20
+ occurrence: z.number().int().positive().optional(),
21
+ occurrence_index: z.number().int().positive().optional(),
12
22
  });
13
23
  function resolvePath(cwd, filePath) {
14
24
  return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
15
25
  }
26
+ function normalizePatchPath(filePath) {
27
+ if (!filePath) {
28
+ return null;
29
+ }
30
+ return filePath.replace(/^(a|b)\//, '');
31
+ }
32
+ function getPathBasename(filePath) {
33
+ const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
34
+ return segments[segments.length - 1] ?? filePath;
35
+ }
36
+ function extractSinglePatch(patchText) {
37
+ const parsed = parsePatch(patchText);
38
+ if (parsed.length === 0) {
39
+ return { error: 'Patch could not be parsed. Provide a valid unified diff.' };
40
+ }
41
+ if (parsed.length > 1) {
42
+ return {
43
+ error: 'Patch mode currently supports exactly one file diff. Split multi-file patches into separate edit_file calls.',
44
+ };
45
+ }
46
+ const patch = parsed[0];
47
+ const targetPath = normalizePatchPath(patch.newFileName) ?? normalizePatchPath(patch.oldFileName);
48
+ return { patch, targetPath };
49
+ }
50
+ function patchTouchedLines(patch) {
51
+ const lines = new Set();
52
+ for (const hunk of patch.hunks) {
53
+ const removedOnly = hunk.lines.every(line => line.startsWith('-'));
54
+ const start = removedOnly ? hunk.oldStart : hunk.newStart;
55
+ const length = removedOnly ? hunk.oldLines : hunk.newLines;
56
+ const safeLength = Math.max(length, 1);
57
+ for (let offset = 0; offset < safeLength; offset += 1) {
58
+ lines.add(start + offset);
59
+ }
60
+ }
61
+ return [...lines].sort((left, right) => left - right);
62
+ }
63
+ function buildPatchMismatchDiagnostic(inputPath, patchPath) {
64
+ return [
65
+ 'Patch target path does not match the requested file path.',
66
+ `Input path: ${inputPath}`,
67
+ `Patch path: ${patchPath}`,
68
+ 'Either omit path and let the patch header decide, or make them match exactly.',
69
+ ].join('\n');
70
+ }
71
+ function applySingleFilePatch(current, patchText) {
72
+ const parsed = extractSinglePatch(patchText);
73
+ if ('error' in parsed) {
74
+ return parsed;
75
+ }
76
+ const updated = applyUnifiedPatch(current, parsed.patch, {
77
+ fuzzFactor: 0,
78
+ });
79
+ if (updated === false) {
80
+ return {
81
+ error: [
82
+ 'Patch could not be applied cleanly.',
83
+ 'The file content may have changed, or the patch context does not match exactly.',
84
+ 'Re-read the file and generate a fresh unified diff.',
85
+ ].join('\n'),
86
+ };
87
+ }
88
+ return {
89
+ updated,
90
+ touchedLines: patchTouchedLines(parsed.patch),
91
+ patchPath: parsed.targetPath,
92
+ };
93
+ }
94
+ function getLineNumberAtIndex(source, index) {
95
+ return source.slice(0, index).split(/\r?\n/).length;
96
+ }
97
+ function buildLinePreview(source, lineNumber) {
98
+ const lines = source.split(/\r?\n/);
99
+ const start = Math.max(lineNumber - 2, 0);
100
+ const end = Math.min(lineNumber + 1, lines.length);
101
+ return lines
102
+ .slice(start, end)
103
+ .map((line, offset) => `${start + offset + 1}: ${line}`)
104
+ .join('\n');
105
+ }
106
+ function buildNotFoundDiagnostic(source, oldText) {
107
+ const firstAnchor = oldText
108
+ .split(/\r?\n/)
109
+ .map(line => line.trim())
110
+ .find(Boolean);
111
+ if (!firstAnchor) {
112
+ return 'oldText was not found in the target file.';
113
+ }
114
+ const lines = source.split(/\r?\n/);
115
+ const anchorLower = firstAnchor.toLowerCase();
116
+ const matchingLines = lines
117
+ .map((line, index) => ({ line, lineNumber: index + 1 }))
118
+ .filter(item => item.line.toLowerCase().includes(anchorLower))
119
+ .slice(0, 3);
120
+ if (matchingLines.length === 0) {
121
+ const whitespaceNormalized = stripTrailingWhitespace(oldText);
122
+ const sourceWithoutTrailingWhitespace = stripTrailingWhitespace(source);
123
+ if (whitespaceNormalized !== oldText &&
124
+ sourceWithoutTrailingWhitespace.includes(whitespaceNormalized)) {
125
+ return [
126
+ 'oldText was not found exactly in the target file.',
127
+ 'A trailing-whitespace-normalized version appears to exist.',
128
+ 'Try reading the file again and resend oldText without trailing spaces.',
129
+ ].join('\n');
130
+ }
131
+ return 'oldText was not found in the target file.';
132
+ }
133
+ return [
134
+ 'oldText was not found exactly in the target file.',
135
+ 'Closest anchor matches:',
136
+ ...matchingLines.map(item => `${item.lineNumber}: ${item.line}`),
137
+ ].join('\n');
138
+ }
16
139
  export const editFileTool = {
17
140
  name: 'edit_file',
18
- description: 'Replace the first occurrence of oldText with newText in a text file.',
141
+ description: 'Edit a file either by replacing text or by applying a single-file unified diff patch.',
19
142
  inputSchema,
20
143
  inputSchemaJson: {
21
144
  type: 'object',
22
145
  properties: {
23
146
  path: { type: 'string' },
147
+ patch: {
148
+ type: 'string',
149
+ description: 'Unified diff patch for a single file. When provided, oldText/newText are not required.',
150
+ },
24
151
  oldText: { type: 'string' },
25
152
  newText: { type: 'string' },
153
+ replaceAll: { type: 'boolean' },
154
+ occurrence: {
155
+ type: 'integer',
156
+ description: '1-based occurrence index to replace when multiple exact matches exist.',
157
+ },
26
158
  },
27
- required: ['path', 'oldText', 'newText'],
28
159
  additionalProperties: false,
29
160
  },
30
161
  isReadOnly: false,
31
162
  async execute(input, context) {
32
- if (context.approvalMode === 'manual') {
163
+ const normalizedPath = input.path ?? input.filePath ?? input.file_path;
164
+ const patchText = input.patch ?? input.diff ?? input.unifiedDiff ?? input.unified_diff;
165
+ const oldText = input.oldText ?? input.old_text;
166
+ const newText = input.newText ?? input.new_text;
167
+ const replaceAll = input.replaceAll ?? input.replace_all ?? false;
168
+ const occurrence = input.occurrence ?? input.occurrence_index;
169
+ if (patchText !== undefined && (oldText !== undefined || newText !== undefined)) {
33
170
  return {
34
171
  isError: true,
35
- content: 'edit_file is blocked in manual approval mode. Switch config approvalMode to auto to enable file edits.',
172
+ content: 'Use either patch mode or oldText/newText mode, not both in the same call.',
36
173
  };
37
174
  }
38
- const normalizedPath = input.path ?? input.filePath ?? input.file_path;
39
- const oldText = input.oldText ?? input.old_text;
40
- const newText = input.newText ?? input.new_text;
41
- if (!normalizedPath || oldText === undefined || newText === undefined) {
175
+ if (patchText === undefined && (!normalizedPath || oldText === undefined || newText === undefined)) {
176
+ return {
177
+ isError: true,
178
+ content: 'Missing required fields. Use path + oldText + newText, or provide patch (and optionally path).',
179
+ };
180
+ }
181
+ if (patchText === undefined && oldText !== undefined && oldText.length === 0) {
42
182
  return {
43
183
  isError: true,
44
- content: 'Missing required fields: path, oldText, or newText',
184
+ content: 'oldText must not be empty.',
45
185
  };
46
186
  }
47
- const absolutePath = resolvePath(context.cwd, normalizedPath);
48
- const current = await readFile(absolutePath, 'utf8');
49
- if (!current.includes(oldText)) {
187
+ if (patchText !== undefined && (replaceAll || occurrence !== undefined)) {
50
188
  return {
51
189
  isError: true,
52
- content: 'oldText was not found in the target file.',
190
+ content: 'replaceAll and occurrence are only supported in oldText/newText mode.',
53
191
  };
54
192
  }
55
- const next = current.replace(oldText, newText);
193
+ if (patchText === undefined && replaceAll && occurrence !== undefined) {
194
+ return {
195
+ isError: true,
196
+ content: 'Use either replaceAll or occurrence, not both.',
197
+ };
198
+ }
199
+ let resolvedPath = normalizedPath;
200
+ if (patchText !== undefined) {
201
+ const parsed = extractSinglePatch(patchText);
202
+ if ('error' in parsed) {
203
+ return {
204
+ isError: true,
205
+ content: parsed.error,
206
+ };
207
+ }
208
+ if (!resolvedPath && parsed.targetPath) {
209
+ resolvedPath = parsed.targetPath;
210
+ }
211
+ if (!resolvedPath) {
212
+ return {
213
+ isError: true,
214
+ content: 'Patch mode requires a path field or a patch header with a target file path.',
215
+ };
216
+ }
217
+ if (parsed.targetPath && normalizePatchPath(resolvedPath) !== normalizePatchPath(parsed.targetPath)) {
218
+ return {
219
+ isError: true,
220
+ content: buildPatchMismatchDiagnostic(resolvedPath, parsed.targetPath),
221
+ };
222
+ }
223
+ }
224
+ const absolutePath = resolvePath(context.cwd, resolvedPath);
225
+ let current;
226
+ try {
227
+ current = await readFile(absolutePath, 'utf8');
228
+ }
229
+ catch (error) {
230
+ return {
231
+ isError: true,
232
+ content: error instanceof Error ? error.message : String(error),
233
+ };
234
+ }
235
+ if (patchText !== undefined) {
236
+ const patchResult = applySingleFilePatch(current, patchText);
237
+ if ('error' in patchResult) {
238
+ return {
239
+ isError: true,
240
+ content: patchResult.error,
241
+ };
242
+ }
243
+ await writeFile(absolutePath, patchResult.updated, 'utf8');
244
+ return {
245
+ content: `Applied patch to ${resolvedPath} touching line${patchResult.touchedLines.length === 1 ? '' : 's'} ${patchResult.touchedLines.join(', ')}.`,
246
+ };
247
+ }
248
+ const requiredOldText = oldText;
249
+ const requiredNewText = newText;
250
+ const matches = findEditMatches(current, requiredOldText);
251
+ if (matches.length === 0) {
252
+ return {
253
+ isError: true,
254
+ content: buildNotFoundDiagnostic(current, requiredOldText),
255
+ };
256
+ }
257
+ let next;
258
+ let replacedCount;
259
+ let replacedLines;
260
+ let matchTypeLabel = matches[0]?.matchType === 'normalized_quotes'
261
+ ? 'normalized quote match'
262
+ : 'exact match';
263
+ if (replaceAll) {
264
+ next = applyMatches(current, requiredOldText, requiredNewText, matches);
265
+ replacedCount = matches.length;
266
+ replacedLines = matches.map(match => getLineNumberAtIndex(current, match.index));
267
+ }
268
+ else if (occurrence !== undefined) {
269
+ if (occurrence > matches.length) {
270
+ return {
271
+ isError: true,
272
+ content: `Requested occurrence ${occurrence}, but only found ${matches.length} exact match(es).`,
273
+ };
274
+ }
275
+ const selected = matches[occurrence - 1];
276
+ next = applyMatches(current, requiredOldText, requiredNewText, [selected]);
277
+ replacedCount = 1;
278
+ replacedLines = [getLineNumberAtIndex(current, selected.index)];
279
+ matchTypeLabel = selected.matchType === 'normalized_quotes'
280
+ ? 'normalized quote match'
281
+ : 'exact match';
282
+ }
283
+ else if (matches.length > 1) {
284
+ const previews = matches.slice(0, 3).map(match => {
285
+ const lineNumber = getLineNumberAtIndex(current, match.index);
286
+ return `${match.matchType === 'normalized_quotes' ? '[normalized-quotes]' : '[exact]'}\n${buildLinePreview(current, lineNumber)}`;
287
+ });
288
+ return {
289
+ isError: true,
290
+ content: [
291
+ `Found ${matches.length} ${matches[0]?.matchType === 'normalized_quotes' ? 'normalized quote' : 'exact'} matches for oldText in ${resolvedPath}.`,
292
+ 'Specify occurrence (1-based) or set replaceAll=true.',
293
+ 'Example matching locations:',
294
+ ...previews,
295
+ ].join('\n---\n'),
296
+ };
297
+ }
298
+ else {
299
+ next = applyMatches(current, requiredOldText, requiredNewText, [matches[0]]);
300
+ replacedCount = 1;
301
+ replacedLines = [getLineNumberAtIndex(current, matches[0].index)];
302
+ }
56
303
  await writeFile(absolutePath, next, 'utf8');
57
304
  return {
58
- content: `Updated ${normalizedPath}`,
305
+ content: `Updated ${resolvedPath} (${replacedCount} replacement${replacedCount === 1 ? '' : 's'}) using ${matchTypeLabel} at line${replacedLines.length === 1 ? '' : 's'} ${replacedLines.join(', ')}.`,
59
306
  };
60
307
  },
61
308
  };
@@ -0,0 +1,15 @@
1
+ export declare const LEFT_SINGLE_CURLY_QUOTE = "\u2018";
2
+ export declare const RIGHT_SINGLE_CURLY_QUOTE = "\u2019";
3
+ export declare const LEFT_DOUBLE_CURLY_QUOTE = "\u201C";
4
+ export declare const RIGHT_DOUBLE_CURLY_QUOTE = "\u201D";
5
+ export type EditMatch = {
6
+ index: number;
7
+ actual: string;
8
+ matchType: 'exact' | 'normalized_quotes';
9
+ };
10
+ export declare function normalizeQuotes(value: string): string;
11
+ export declare function stripTrailingWhitespace(value: string): string;
12
+ export declare function findExactMatchIndices(source: string, target: string): number[];
13
+ export declare function preserveQuoteStyle(requestedOldText: string, actualOldText: string, requestedNewText: string): string;
14
+ export declare function findEditMatches(source: string, requestedOldText: string): EditMatch[];
15
+ export declare function applyMatches(source: string, requestedOldText: string, requestedNewText: string, matches: EditMatch[]): string;
@@ -0,0 +1,142 @@
1
+ export const LEFT_SINGLE_CURLY_QUOTE = '‘';
2
+ export const RIGHT_SINGLE_CURLY_QUOTE = '’';
3
+ export const LEFT_DOUBLE_CURLY_QUOTE = '“';
4
+ export const RIGHT_DOUBLE_CURLY_QUOTE = '”';
5
+ export function normalizeQuotes(value) {
6
+ return value
7
+ .replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
8
+ .replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
9
+ .replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
10
+ .replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
11
+ }
12
+ export function stripTrailingWhitespace(value) {
13
+ const parts = value.split(/(\r\n|\n|\r)/);
14
+ let result = '';
15
+ for (let index = 0; index < parts.length; index += 1) {
16
+ const part = parts[index];
17
+ if (part === undefined) {
18
+ continue;
19
+ }
20
+ result += index % 2 === 0 ? part.replace(/\s+$/, '') : part;
21
+ }
22
+ return result;
23
+ }
24
+ export function findExactMatchIndices(source, target) {
25
+ const indices = [];
26
+ if (!target) {
27
+ return indices;
28
+ }
29
+ let searchStart = 0;
30
+ while (searchStart <= source.length) {
31
+ const index = source.indexOf(target, searchStart);
32
+ if (index === -1) {
33
+ break;
34
+ }
35
+ indices.push(index);
36
+ searchStart = index + Math.max(target.length, 1);
37
+ }
38
+ return indices;
39
+ }
40
+ function isOpeningContext(characters, index) {
41
+ if (index === 0) {
42
+ return true;
43
+ }
44
+ const previous = characters[index - 1];
45
+ return (previous === ' ' ||
46
+ previous === '\t' ||
47
+ previous === '\n' ||
48
+ previous === '\r' ||
49
+ previous === '(' ||
50
+ previous === '[' ||
51
+ previous === '{' ||
52
+ previous === '\u2014' ||
53
+ previous === '\u2013');
54
+ }
55
+ function applyCurlyDoubleQuotes(value) {
56
+ const characters = [...value];
57
+ const result = [];
58
+ for (let index = 0; index < characters.length; index += 1) {
59
+ const character = characters[index];
60
+ if (character === '"') {
61
+ result.push(isOpeningContext(characters, index)
62
+ ? LEFT_DOUBLE_CURLY_QUOTE
63
+ : RIGHT_DOUBLE_CURLY_QUOTE);
64
+ continue;
65
+ }
66
+ result.push(character ?? '');
67
+ }
68
+ return result.join('');
69
+ }
70
+ function applyCurlySingleQuotes(value) {
71
+ const characters = [...value];
72
+ const result = [];
73
+ for (let index = 0; index < characters.length; index += 1) {
74
+ const character = characters[index];
75
+ if (character === "'") {
76
+ const previous = index > 0 ? characters[index - 1] : undefined;
77
+ const next = index < characters.length - 1 ? characters[index + 1] : undefined;
78
+ const previousIsLetter = previous !== undefined && /\p{L}/u.test(previous);
79
+ const nextIsLetter = next !== undefined && /\p{L}/u.test(next);
80
+ if (previousIsLetter && nextIsLetter) {
81
+ result.push(RIGHT_SINGLE_CURLY_QUOTE);
82
+ }
83
+ else {
84
+ result.push(isOpeningContext(characters, index)
85
+ ? LEFT_SINGLE_CURLY_QUOTE
86
+ : RIGHT_SINGLE_CURLY_QUOTE);
87
+ }
88
+ continue;
89
+ }
90
+ result.push(character ?? '');
91
+ }
92
+ return result.join('');
93
+ }
94
+ export function preserveQuoteStyle(requestedOldText, actualOldText, requestedNewText) {
95
+ if (requestedOldText === actualOldText) {
96
+ return requestedNewText;
97
+ }
98
+ const hasDoubleCurlyQuotes = actualOldText.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
99
+ actualOldText.includes(RIGHT_DOUBLE_CURLY_QUOTE);
100
+ const hasSingleCurlyQuotes = actualOldText.includes(LEFT_SINGLE_CURLY_QUOTE) ||
101
+ actualOldText.includes(RIGHT_SINGLE_CURLY_QUOTE);
102
+ if (!hasDoubleCurlyQuotes && !hasSingleCurlyQuotes) {
103
+ return requestedNewText;
104
+ }
105
+ let result = requestedNewText;
106
+ if (hasDoubleCurlyQuotes) {
107
+ result = applyCurlyDoubleQuotes(result);
108
+ }
109
+ if (hasSingleCurlyQuotes) {
110
+ result = applyCurlySingleQuotes(result);
111
+ }
112
+ return result;
113
+ }
114
+ export function findEditMatches(source, requestedOldText) {
115
+ const exactMatches = findExactMatchIndices(source, requestedOldText);
116
+ if (exactMatches.length > 0) {
117
+ return exactMatches.map(index => ({
118
+ index,
119
+ actual: requestedOldText,
120
+ matchType: 'exact',
121
+ }));
122
+ }
123
+ const normalizedOldText = normalizeQuotes(requestedOldText);
124
+ const normalizedSource = normalizeQuotes(source);
125
+ const normalizedIndices = findExactMatchIndices(normalizedSource, normalizedOldText);
126
+ return normalizedIndices.map(index => ({
127
+ index,
128
+ actual: source.slice(index, index + requestedOldText.length),
129
+ matchType: 'normalized_quotes',
130
+ }));
131
+ }
132
+ export function applyMatches(source, requestedOldText, requestedNewText, matches) {
133
+ let next = source;
134
+ for (const match of [...matches].sort((left, right) => right.index - left.index)) {
135
+ const replacement = preserveQuoteStyle(requestedOldText, match.actual, requestedNewText);
136
+ next =
137
+ next.slice(0, match.index) +
138
+ replacement +
139
+ next.slice(match.index + match.actual.length);
140
+ }
141
+ return next;
142
+ }
@@ -1,25 +1,74 @@
1
+ import { buildApprovalRequest } from './permissions.js';
2
+ function finalizeResult(result, context, startedAt, approval, failureStage) {
3
+ return {
4
+ ...result,
5
+ meta: {
6
+ cwd: context.cwd,
7
+ durationMs: Date.now() - startedAt,
8
+ approval,
9
+ failureStage,
10
+ },
11
+ };
12
+ }
1
13
  export async function executeToolCall(toolUse, tools, context) {
14
+ const startedAt = Date.now();
2
15
  const tool = tools.find(item => item.name === toolUse.name);
3
16
  if (!tool) {
4
- return {
17
+ return finalizeResult({
5
18
  isError: true,
6
19
  content: `Unknown tool: ${toolUse.name}`,
7
- };
20
+ }, context, startedAt, {
21
+ mode: context.approvalMode,
22
+ required: false,
23
+ }, 'lookup');
8
24
  }
9
25
  const parsed = tool.inputSchema.safeParse(toolUse.input);
10
26
  if (!parsed.success) {
11
- return {
27
+ return finalizeResult({
12
28
  isError: true,
13
29
  content: parsed.error.message,
14
- };
30
+ }, context, startedAt, {
31
+ mode: context.approvalMode,
32
+ required: false,
33
+ }, 'validation');
34
+ }
35
+ const approvalRequest = buildApprovalRequest(tool, parsed.data, context);
36
+ const approvalMeta = {
37
+ mode: context.approvalMode,
38
+ required: Boolean(approvalRequest),
39
+ risk: approvalRequest?.risk,
40
+ targets: approvalRequest?.targets,
41
+ };
42
+ if (approvalRequest) {
43
+ if (!context.requestApproval) {
44
+ return finalizeResult({
45
+ isError: true,
46
+ content: 'This tool requires manual approval, but no approval handler is available in the current session.',
47
+ }, context, startedAt, {
48
+ ...approvalMeta,
49
+ approved: false,
50
+ }, 'approval');
51
+ }
52
+ const decision = await context.requestApproval(approvalRequest);
53
+ if (!decision.approved) {
54
+ return finalizeResult({
55
+ isError: true,
56
+ content: decision.reason ?? `User denied ${tool.name}.`,
57
+ }, context, startedAt, {
58
+ ...approvalMeta,
59
+ approved: false,
60
+ }, 'approval');
61
+ }
62
+ approvalMeta.approved = true;
15
63
  }
16
64
  try {
17
- return await tool.execute(parsed.data, context);
65
+ const result = await tool.execute(parsed.data, context);
66
+ return finalizeResult(result, context, startedAt, approvalMeta);
18
67
  }
19
68
  catch (error) {
20
- return {
69
+ return finalizeResult({
21
70
  isError: true,
22
71
  content: error instanceof Error ? error.message : String(error),
23
- };
72
+ }, context, startedAt, approvalMeta, 'execution');
24
73
  }
25
74
  }
@@ -0,0 +1,12 @@
1
+ import type { ToolContext, ToolDefinition } from './types.js';
2
+ export declare const DANGEROUS_FILES: readonly [".gitconfig", ".gitmodules", ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile", ".mcp.json", ".claude.json"];
3
+ export declare const DANGEROUS_DIRECTORIES: readonly [".git", ".vscode", ".idea", ".claude"];
4
+ export type ToolApprovalRequest = {
5
+ toolName: string;
6
+ summary: string;
7
+ reason: string;
8
+ targets: string[];
9
+ risk: 'low' | 'medium' | 'high';
10
+ input: unknown;
11
+ };
12
+ export declare function buildApprovalRequest(tool: ToolDefinition, input: unknown, context: ToolContext): ToolApprovalRequest | null;
@@ -0,0 +1,105 @@
1
+ import { isAbsolute, normalize, relative, resolve, sep } from 'node:path';
2
+ export const DANGEROUS_FILES = [
3
+ '.gitconfig',
4
+ '.gitmodules',
5
+ '.bashrc',
6
+ '.bash_profile',
7
+ '.zshrc',
8
+ '.zprofile',
9
+ '.profile',
10
+ '.mcp.json',
11
+ '.claude.json',
12
+ ];
13
+ export const DANGEROUS_DIRECTORIES = ['.git', '.vscode', '.idea', '.claude'];
14
+ function normalizeCaseForComparison(filePath) {
15
+ return filePath.toLowerCase();
16
+ }
17
+ function resolveTargetPath(cwd, targetPath) {
18
+ return isAbsolute(targetPath) ? normalize(targetPath) : normalize(resolve(cwd, targetPath));
19
+ }
20
+ function toDisplayPath(cwd, absolutePath) {
21
+ const relativePath = relative(cwd, absolutePath);
22
+ if (!relativePath || relativePath.startsWith(`..${sep}`) || relativePath === '..') {
23
+ return absolutePath;
24
+ }
25
+ return relativePath;
26
+ }
27
+ function isDangerousPath(absolutePath) {
28
+ const normalizedPath = normalizeCaseForComparison(absolutePath);
29
+ const pathSegments = normalizedPath.split(/[\\/]+/).filter(Boolean);
30
+ const fileName = pathSegments[pathSegments.length - 1];
31
+ if (fileName && DANGEROUS_FILES.includes(fileName)) {
32
+ return true;
33
+ }
34
+ return pathSegments.some(segment => DANGEROUS_DIRECTORIES.includes(segment));
35
+ }
36
+ function extractPatchTarget(patchText) {
37
+ const lines = patchText.split(/\r?\n/);
38
+ for (const line of lines) {
39
+ if (line.startsWith('+++ ') || line.startsWith('--- ')) {
40
+ const rawPath = line.slice(4).trim();
41
+ if (!rawPath || rawPath === '/dev/null') {
42
+ continue;
43
+ }
44
+ return rawPath.replace(/^(a|b)\//, '');
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ function extractCandidateTargets(toolName, input) {
50
+ if (!input || typeof input !== 'object') {
51
+ return [];
52
+ }
53
+ const record = input;
54
+ const directPath = [record.path, record.filePath, record.file_path]
55
+ .find(value => typeof value === 'string' && value.trim().length > 0);
56
+ if (toolName === 'grep') {
57
+ const searchPath = [record.path, record.directory]
58
+ .find(value => typeof value === 'string' && value.trim().length > 0);
59
+ return searchPath ? [String(searchPath)] : [];
60
+ }
61
+ if (toolName === 'run_shell') {
62
+ return [];
63
+ }
64
+ const patchText = [record.patch, record.diff, record.unifiedDiff, record.unified_diff]
65
+ .find(value => typeof value === 'string' && value.trim().length > 0);
66
+ const patchPath = typeof patchText === 'string' ? extractPatchTarget(patchText) : null;
67
+ return [directPath, patchPath].filter((value) => Boolean(value));
68
+ }
69
+ export function buildApprovalRequest(tool, input, context) {
70
+ if (context.approvalMode === 'auto') {
71
+ return null;
72
+ }
73
+ if (tool.isReadOnly) {
74
+ return null;
75
+ }
76
+ const candidateTargets = extractCandidateTargets(tool.name, input);
77
+ const absoluteTargets = candidateTargets.map(target => resolveTargetPath(context.cwd, target));
78
+ const displayTargets = absoluteTargets.map(target => toDisplayPath(context.cwd, target));
79
+ const hasDangerousTarget = absoluteTargets.some(isDangerousPath);
80
+ if (tool.name === 'run_shell') {
81
+ const command = input && typeof input === 'object'
82
+ ? input.command ?? input.cmd
83
+ : undefined;
84
+ return {
85
+ toolName: tool.name,
86
+ summary: `Shell execution requested${typeof command === 'string' ? `: ${command}` : '.'}`,
87
+ reason: 'run_shell can execute arbitrary commands and modify the filesystem or environment.',
88
+ targets: [],
89
+ risk: 'high',
90
+ input,
91
+ };
92
+ }
93
+ return {
94
+ toolName: tool.name,
95
+ summary: displayTargets.length > 0
96
+ ? `Write access requested for ${displayTargets.join(', ')}`
97
+ : 'Write access requested.',
98
+ reason: hasDangerousTarget
99
+ ? 'Target includes a protected configuration or metadata path from the Claude Code-style danger list.'
100
+ : 'This tool can modify files on disk.',
101
+ targets: displayTargets,
102
+ risk: hasDangerousTarget ? 'high' : 'medium',
103
+ input,
104
+ };
105
+ }