@jx-grxf/patchpilot 1.0.0 → 1.2.0
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.
- package/README.md +51 -16
- package/dist/cli.js +46 -3
- package/dist/cli.js.map +1 -1
- package/dist/core/agent.d.ts +44 -1
- package/dist/core/agent.js +617 -70
- package/dist/core/agent.js.map +1 -1
- package/dist/core/clipboard.d.ts +14 -0
- package/dist/core/clipboard.js +134 -0
- package/dist/core/clipboard.js.map +1 -0
- package/dist/core/codex.d.ts +8 -0
- package/dist/core/codex.js +28 -2
- package/dist/core/codex.js.map +1 -1
- package/dist/core/compaction.d.ts +23 -0
- package/dist/core/compaction.js +145 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/contextFormat.d.ts +21 -0
- package/dist/core/contextFormat.js +87 -0
- package/dist/core/contextFormat.js.map +1 -0
- package/dist/core/contextItem.d.ts +41 -0
- package/dist/core/contextItem.js +93 -0
- package/dist/core/contextItem.js.map +1 -0
- package/dist/core/contextStore.d.ts +48 -0
- package/dist/core/contextStore.js +306 -0
- package/dist/core/contextStore.js.map +1 -0
- package/dist/core/doctor.js +9 -8
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/gemini.js +10 -4
- package/dist/core/gemini.js.map +1 -1
- package/dist/core/geminiWrapper.d.ts +43 -2
- package/dist/core/geminiWrapper.js +582 -42
- package/dist/core/geminiWrapper.js.map +1 -1
- package/dist/core/http.js +70 -6
- package/dist/core/http.js.map +1 -1
- package/dist/core/json.d.ts +1 -1
- package/dist/core/json.js +18 -20
- package/dist/core/json.js.map +1 -1
- package/dist/core/nvidia.d.ts +1 -1
- package/dist/core/nvidia.js +13 -4
- package/dist/core/nvidia.js.map +1 -1
- package/dist/core/ollama.js +13 -3
- package/dist/core/ollama.js.map +1 -1
- package/dist/core/openrouter.js +15 -6
- package/dist/core/openrouter.js.map +1 -1
- package/dist/core/reasoning.js +3 -0
- package/dist/core/reasoning.js.map +1 -1
- package/dist/core/session.js +9 -3
- package/dist/core/session.js.map +1 -1
- package/dist/core/tokenAccounting.d.ts +4 -0
- package/dist/core/tokenAccounting.js +75 -13
- package/dist/core/tokenAccounting.js.map +1 -1
- package/dist/core/types.d.ts +58 -3
- package/dist/core/types.js +30 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/updateCheck.d.ts +19 -0
- package/dist/core/updateCheck.js +103 -0
- package/dist/core/updateCheck.js.map +1 -0
- package/dist/core/workspace.d.ts +29 -0
- package/dist/core/workspace.js +1271 -92
- package/dist/core/workspace.js.map +1 -1
- package/dist/tui/App.d.ts +1 -0
- package/dist/tui/App.js +1346 -112
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/commands.js +109 -6
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/components/ApprovalPanel.js +16 -1
- package/dist/tui/components/ApprovalPanel.js.map +1 -1
- package/dist/tui/components/CommandSuggestions.js +26 -3
- package/dist/tui/components/CommandSuggestions.js.map +1 -1
- package/dist/tui/components/Composer.d.ts +3 -0
- package/dist/tui/components/Composer.js +57 -5
- package/dist/tui/components/Composer.js.map +1 -1
- package/dist/tui/components/ExperimentalPanel.d.ts +1 -1
- package/dist/tui/components/ExperimentalPanel.js +5 -0
- package/dist/tui/components/ExperimentalPanel.js.map +1 -1
- package/dist/tui/components/OnboardingPanel.d.ts +12 -0
- package/dist/tui/components/OnboardingPanel.js +69 -21
- package/dist/tui/components/OnboardingPanel.js.map +1 -1
- package/dist/tui/components/StartupBanner.d.ts +4 -0
- package/dist/tui/components/StartupBanner.js +9 -0
- package/dist/tui/components/StartupBanner.js.map +1 -0
- package/dist/tui/components/Transcript.d.ts +7 -0
- package/dist/tui/components/Transcript.js +86 -16
- package/dist/tui/components/Transcript.js.map +1 -1
- package/dist/tui/contextCommands.d.ts +8 -0
- package/dist/tui/contextCommands.js +205 -0
- package/dist/tui/contextCommands.js.map +1 -0
- package/dist/tui/experimental/AnimatedText.d.ts +38 -0
- package/dist/tui/experimental/AnimatedText.js +55 -0
- package/dist/tui/experimental/AnimatedText.js.map +1 -0
- package/dist/tui/experimental/Banner.d.ts +10 -0
- package/dist/tui/experimental/Banner.js +33 -0
- package/dist/tui/experimental/Banner.js.map +1 -0
- package/dist/tui/experimental/CommandPalette.d.ts +11 -0
- package/dist/tui/experimental/CommandPalette.js +25 -0
- package/dist/tui/experimental/CommandPalette.js.map +1 -0
- package/dist/tui/experimental/ExperimentalShell.d.ts +58 -0
- package/dist/tui/experimental/ExperimentalShell.js +366 -0
- package/dist/tui/experimental/ExperimentalShell.js.map +1 -0
- package/dist/tui/experimental/ThemePicker.d.ts +13 -0
- package/dist/tui/experimental/ThemePicker.js +12 -0
- package/dist/tui/experimental/ThemePicker.js.map +1 -0
- package/dist/tui/experimental/attachments.d.ts +35 -0
- package/dist/tui/experimental/attachments.js +244 -0
- package/dist/tui/experimental/attachments.js.map +1 -0
- package/dist/tui/experimental/composer.d.ts +24 -0
- package/dist/tui/experimental/composer.js +84 -0
- package/dist/tui/experimental/composer.js.map +1 -0
- package/dist/tui/experimental/geminiPricing.d.ts +16 -0
- package/dist/tui/experimental/geminiPricing.js +39 -0
- package/dist/tui/experimental/geminiPricing.js.map +1 -0
- package/dist/tui/experimental/layout.d.ts +46 -0
- package/dist/tui/experimental/layout.js +112 -0
- package/dist/tui/experimental/layout.js.map +1 -0
- package/dist/tui/experimental/theme.d.ts +35 -0
- package/dist/tui/experimental/theme.js +86 -0
- package/dist/tui/experimental/theme.js.map +1 -0
- package/dist/tui/experimental/transcriptRows.d.ts +20 -0
- package/dist/tui/experimental/transcriptRows.js +169 -0
- package/dist/tui/experimental/transcriptRows.js.map +1 -0
- package/dist/tui/experimental/ultraModes.d.ts +46 -0
- package/dist/tui/experimental/ultraModes.js +95 -0
- package/dist/tui/experimental/ultraModes.js.map +1 -0
- package/dist/tui/experimental/ultramaxx.d.ts +19 -0
- package/dist/tui/experimental/ultramaxx.js +43 -0
- package/dist/tui/experimental/ultramaxx.js.map +1 -0
- package/dist/tui/format.d.ts +4 -2
- package/dist/tui/format.js +14 -0
- package/dist/tui/format.js.map +1 -1
- package/dist/tui/hosts.js +7 -1
- package/dist/tui/hosts.js.map +1 -1
- package/dist/tui/layout.d.ts +26 -0
- package/dist/tui/layout.js +66 -0
- package/dist/tui/layout.js.map +1 -0
- package/dist/tui/modelSelection.d.ts +1 -1
- package/dist/tui/modelSelection.js +8 -6
- package/dist/tui/modelSelection.js.map +1 -1
- package/dist/tui/modes.d.ts +7 -0
- package/dist/tui/modes.js +12 -0
- package/dist/tui/modes.js.map +1 -1
- package/dist/tui/onboardingPreferences.d.ts +37 -0
- package/dist/tui/onboardingPreferences.js +118 -0
- package/dist/tui/onboardingPreferences.js.map +1 -0
- package/dist/tui/runStatus.d.ts +50 -0
- package/dist/tui/runStatus.js +164 -0
- package/dist/tui/runStatus.js.map +1 -0
- package/dist/tui/types.d.ts +8 -0
- package/dist/tui/types.js.map +1 -1
- package/docs/architecture.md +115 -0
- package/docs/gemini-wrapper.md +23 -0
- package/docs/product-context.md +43 -0
- package/docs/releases/v1.0.1.md +25 -0
- package/docs/releases/v1.1.0.md +30 -0
- package/docs/releases/v1.2.0.md +28 -0
- package/package.json +4 -2
package/dist/core/workspace.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { constants } from "node:fs";
|
|
3
|
-
import { access, lstat, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { platform } from "node:os";
|
|
4
|
+
import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, platform, tmpdir } from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
|
-
import { inflateRawSync } from "node:zlib";
|
|
8
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
8
9
|
import { MemoryStore } from "./memory.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const ignoredDirectories = new Set([
|
|
@@ -28,6 +29,7 @@ const textFileExtensions = new Set([
|
|
|
28
29
|
".h",
|
|
29
30
|
".hpp",
|
|
30
31
|
".html",
|
|
32
|
+
".svg",
|
|
31
33
|
".js",
|
|
32
34
|
".json",
|
|
33
35
|
".jsx",
|
|
@@ -39,6 +41,7 @@ const textFileExtensions = new Set([
|
|
|
39
41
|
".ts",
|
|
40
42
|
".tsx",
|
|
41
43
|
".txt",
|
|
44
|
+
".jsonl",
|
|
42
45
|
".java",
|
|
43
46
|
".kt",
|
|
44
47
|
".go",
|
|
@@ -80,6 +83,14 @@ const blockedPathPatterns = [
|
|
|
80
83
|
/(^|\/)(default|profile \d+|profiles?)\/(cookies|network\/cookies|login data|web data)$/i
|
|
81
84
|
];
|
|
82
85
|
export const toolSpecs = {
|
|
86
|
+
update_todo: {
|
|
87
|
+
name: "update_todo",
|
|
88
|
+
description: "Update the agent's visible task checklist.",
|
|
89
|
+
risk: "low",
|
|
90
|
+
sideEffects: "none",
|
|
91
|
+
permission: "none",
|
|
92
|
+
category: "state"
|
|
93
|
+
},
|
|
83
94
|
list_files: {
|
|
84
95
|
name: "list_files",
|
|
85
96
|
description: "List workspace files under a directory.",
|
|
@@ -88,6 +99,14 @@ export const toolSpecs = {
|
|
|
88
99
|
permission: "none",
|
|
89
100
|
category: "read"
|
|
90
101
|
},
|
|
102
|
+
find_files: {
|
|
103
|
+
name: "find_files",
|
|
104
|
+
description: "Find workspace files by path/name substring.",
|
|
105
|
+
risk: "low",
|
|
106
|
+
sideEffects: "none",
|
|
107
|
+
permission: "none",
|
|
108
|
+
category: "search"
|
|
109
|
+
},
|
|
91
110
|
read_file: {
|
|
92
111
|
name: "read_file",
|
|
93
112
|
description: "Read a complete text/code file.",
|
|
@@ -160,6 +179,22 @@ export const toolSpecs = {
|
|
|
160
179
|
permission: "none",
|
|
161
180
|
category: "git"
|
|
162
181
|
},
|
|
182
|
+
git_log: {
|
|
183
|
+
name: "git_log",
|
|
184
|
+
description: "Read recent Git commits.",
|
|
185
|
+
risk: "low",
|
|
186
|
+
sideEffects: "none",
|
|
187
|
+
permission: "none",
|
|
188
|
+
category: "git"
|
|
189
|
+
},
|
|
190
|
+
git_show: {
|
|
191
|
+
name: "git_show",
|
|
192
|
+
description: "Read a compact Git commit or revision summary.",
|
|
193
|
+
risk: "low",
|
|
194
|
+
sideEffects: "none",
|
|
195
|
+
permission: "none",
|
|
196
|
+
category: "git"
|
|
197
|
+
},
|
|
163
198
|
list_changed_files: {
|
|
164
199
|
name: "list_changed_files",
|
|
165
200
|
description: "List changed files from Git porcelain status.",
|
|
@@ -176,6 +211,30 @@ export const toolSpecs = {
|
|
|
176
211
|
permission: "none",
|
|
177
212
|
category: "read"
|
|
178
213
|
},
|
|
214
|
+
repo_overview: {
|
|
215
|
+
name: "repo_overview",
|
|
216
|
+
description: "Read a compact repository overview: package metadata, top-level files, and Git state.",
|
|
217
|
+
risk: "low",
|
|
218
|
+
sideEffects: "none",
|
|
219
|
+
permission: "none",
|
|
220
|
+
category: "read"
|
|
221
|
+
},
|
|
222
|
+
test_list: {
|
|
223
|
+
name: "test_list",
|
|
224
|
+
description: "List likely tests and test scripts without running them.",
|
|
225
|
+
risk: "low",
|
|
226
|
+
sideEffects: "none",
|
|
227
|
+
permission: "none",
|
|
228
|
+
category: "test"
|
|
229
|
+
},
|
|
230
|
+
dependency_tree: {
|
|
231
|
+
name: "dependency_tree",
|
|
232
|
+
description: "Read top-level package dependencies from package.json.",
|
|
233
|
+
risk: "low",
|
|
234
|
+
sideEffects: "none",
|
|
235
|
+
permission: "none",
|
|
236
|
+
category: "read"
|
|
237
|
+
},
|
|
179
238
|
write_file: {
|
|
180
239
|
name: "write_file",
|
|
181
240
|
description: "Write a full file in the workspace.",
|
|
@@ -184,6 +243,30 @@ export const toolSpecs = {
|
|
|
184
243
|
permission: "write",
|
|
185
244
|
category: "write"
|
|
186
245
|
},
|
|
246
|
+
edit_file: {
|
|
247
|
+
name: "edit_file",
|
|
248
|
+
description: "Edit an existing text file by unique find/replace or by replacing a bounded line range with an optional expected-content guard.",
|
|
249
|
+
risk: "high",
|
|
250
|
+
sideEffects: "write",
|
|
251
|
+
permission: "write",
|
|
252
|
+
category: "write"
|
|
253
|
+
},
|
|
254
|
+
create_pdf: {
|
|
255
|
+
name: "create_pdf",
|
|
256
|
+
description: "Create a simple text PDF file in the workspace.",
|
|
257
|
+
risk: "high",
|
|
258
|
+
sideEffects: "write",
|
|
259
|
+
permission: "write",
|
|
260
|
+
category: "write"
|
|
261
|
+
},
|
|
262
|
+
create_docx: {
|
|
263
|
+
name: "create_docx",
|
|
264
|
+
description: "Create a simple text DOCX file in the workspace.",
|
|
265
|
+
risk: "high",
|
|
266
|
+
sideEffects: "write",
|
|
267
|
+
permission: "write",
|
|
268
|
+
category: "write"
|
|
269
|
+
},
|
|
187
270
|
apply_patch: {
|
|
188
271
|
name: "apply_patch",
|
|
189
272
|
description: "Apply a unified Git patch inside the workspace.",
|
|
@@ -225,8 +308,10 @@ export class WorkspaceTools {
|
|
|
225
308
|
rootRealPath;
|
|
226
309
|
allowWrite;
|
|
227
310
|
allowShell;
|
|
311
|
+
allowShellMetacharacters;
|
|
228
312
|
allowExternalFileAnalysis;
|
|
229
313
|
memoryEnabled;
|
|
314
|
+
documentAnalyzer;
|
|
230
315
|
timeoutMs;
|
|
231
316
|
signal;
|
|
232
317
|
approvalHandler;
|
|
@@ -236,7 +321,9 @@ export class WorkspaceTools {
|
|
|
236
321
|
this.rootRealPath = realpath(this.root).catch(() => this.root);
|
|
237
322
|
this.allowWrite = options.allowWrite;
|
|
238
323
|
this.allowShell = options.allowShell;
|
|
324
|
+
this.allowShellMetacharacters = Boolean(options.allowShellMetacharacters);
|
|
239
325
|
this.allowExternalFileAnalysis = Boolean(options.allowExternalFileAnalysis);
|
|
326
|
+
this.documentAnalyzer = options.documentAnalyzer;
|
|
240
327
|
this.memoryEnabled = Boolean(options.memoryEnabled);
|
|
241
328
|
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
242
329
|
this.signal = options.signal;
|
|
@@ -245,8 +332,12 @@ export class WorkspaceTools {
|
|
|
245
332
|
async execute(call) {
|
|
246
333
|
try {
|
|
247
334
|
switch (call.name) {
|
|
335
|
+
case "update_todo":
|
|
336
|
+
return this.updateTodo(call.arguments);
|
|
248
337
|
case "list_files":
|
|
249
338
|
return await this.listFiles(readString(call.arguments.path, "."));
|
|
339
|
+
case "find_files":
|
|
340
|
+
return await this.findFiles(readString(call.arguments.query, ""), readNumber(call.arguments.limit, 80));
|
|
250
341
|
case "read_file":
|
|
251
342
|
return await this.readFile(readString(call.arguments.path, ""));
|
|
252
343
|
case "read_range":
|
|
@@ -256,7 +347,7 @@ export class WorkspaceTools {
|
|
|
256
347
|
case "search_text":
|
|
257
348
|
return await this.searchText(readString(call.arguments.query, ""));
|
|
258
349
|
case "inspect_document":
|
|
259
|
-
return await this.inspectDocument(readString(call.arguments.path, ""));
|
|
350
|
+
return await this.inspectDocument(readString(call.arguments.path, ""), readString(call.arguments.mode, "auto"));
|
|
260
351
|
case "memory_remember":
|
|
261
352
|
return await this.memoryRemember(readString(call.arguments.content, ""), readStringArray(call.arguments.tags));
|
|
262
353
|
case "memory_search":
|
|
@@ -265,12 +356,28 @@ export class WorkspaceTools {
|
|
|
265
356
|
return await this.gitStatus();
|
|
266
357
|
case "git_diff":
|
|
267
358
|
return await this.gitDiff(readString(call.arguments.path, ""));
|
|
359
|
+
case "git_log":
|
|
360
|
+
return await this.gitLog(readNumber(call.arguments.limit, 8));
|
|
361
|
+
case "git_show":
|
|
362
|
+
return await this.gitShow(readString(call.arguments.revision, "HEAD"), readString(call.arguments.path, ""));
|
|
268
363
|
case "list_changed_files":
|
|
269
364
|
return await this.listChangedFiles();
|
|
270
365
|
case "list_scripts":
|
|
271
366
|
return await this.listScripts();
|
|
367
|
+
case "repo_overview":
|
|
368
|
+
return await this.repoOverview();
|
|
369
|
+
case "test_list":
|
|
370
|
+
return await this.testList();
|
|
371
|
+
case "dependency_tree":
|
|
372
|
+
return await this.dependencyTree();
|
|
272
373
|
case "write_file":
|
|
273
374
|
return await this.writeFile(readString(call.arguments.path, ""), readString(call.arguments.content, ""));
|
|
375
|
+
case "edit_file":
|
|
376
|
+
return await this.editFile(readString(call.arguments.path, ""), readString(call.arguments.find, ""), readString(call.arguments.replace, ""), readNumber(call.arguments.startLine, 0), readNumber(call.arguments.endLine, 0), readString(call.arguments.replacement, ""), readOptionalString(call.arguments.expected));
|
|
377
|
+
case "create_pdf":
|
|
378
|
+
return await this.createPdf(readString(call.arguments.path, ""), readString(call.arguments.content, ""), readString(call.arguments.title, ""));
|
|
379
|
+
case "create_docx":
|
|
380
|
+
return await this.createDocx(readString(call.arguments.path, ""), readString(call.arguments.content, ""), readString(call.arguments.title, ""));
|
|
274
381
|
case "apply_patch":
|
|
275
382
|
return await this.applyPatch(readString(call.arguments.patch, ""));
|
|
276
383
|
case "run_script":
|
|
@@ -287,6 +394,18 @@ export class WorkspaceTools {
|
|
|
287
394
|
return denied(error instanceof Error ? error.message : String(error));
|
|
288
395
|
}
|
|
289
396
|
}
|
|
397
|
+
updateTodo(argumentsValue) {
|
|
398
|
+
return {
|
|
399
|
+
ok: true,
|
|
400
|
+
summary: "updated visible todo list",
|
|
401
|
+
content: JSON.stringify({ items: Array.isArray(argumentsValue.items) ? argumentsValue.items : [] }),
|
|
402
|
+
tool: "update_todo",
|
|
403
|
+
category: toolSpecs.update_todo.category,
|
|
404
|
+
metadata: {
|
|
405
|
+
items: Array.isArray(argumentsValue.items) ? argumentsValue.items : []
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
290
409
|
resolveInsideWorkspace(requestedPath) {
|
|
291
410
|
const workspaceRelativePath = this.normalizeWorkspaceRelativePath(requestedPath);
|
|
292
411
|
const absolutePath = path.resolve(this.root, workspaceRelativePath);
|
|
@@ -331,6 +450,35 @@ export class WorkspaceTools {
|
|
|
331
450
|
category: toolSpecs.list_files.category
|
|
332
451
|
};
|
|
333
452
|
}
|
|
453
|
+
async findFiles(query, limit) {
|
|
454
|
+
const normalizedQuery = query.trim().replaceAll("\\", "/").toLowerCase();
|
|
455
|
+
if (!normalizedQuery) {
|
|
456
|
+
return denied("find_files requires a non-empty query.", "find_files");
|
|
457
|
+
}
|
|
458
|
+
if (isPlaceholderPath(normalizedQuery)) {
|
|
459
|
+
return denied(`find_files denied placeholder query: ${query}`, "find_files");
|
|
460
|
+
}
|
|
461
|
+
if (isSensitivePath(normalizedQuery)) {
|
|
462
|
+
return denied(`find_files denied sensitive query: ${query}`, "find_files");
|
|
463
|
+
}
|
|
464
|
+
const normalizedLimit = Math.max(1, Math.min(200, Math.floor(limit || 80)));
|
|
465
|
+
const files = await walkFiles(this.root, this.root, await this.rootRealPath, 10, 1200);
|
|
466
|
+
const matches = files
|
|
467
|
+
.filter((filePath) => filePath.toLowerCase().includes(normalizedQuery))
|
|
468
|
+
.slice(0, normalizedLimit);
|
|
469
|
+
return {
|
|
470
|
+
ok: true,
|
|
471
|
+
summary: `found ${matches.length} file match${matches.length === 1 ? "" : "es"}`,
|
|
472
|
+
content: matches.join("\n") || "No matching files.",
|
|
473
|
+
tool: "find_files",
|
|
474
|
+
category: toolSpecs.find_files.category,
|
|
475
|
+
metadata: {
|
|
476
|
+
query: normalizedQuery,
|
|
477
|
+
limit: normalizedLimit,
|
|
478
|
+
truncated: matches.length >= normalizedLimit
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
334
482
|
async readFile(requestedPath) {
|
|
335
483
|
if (!requestedPath) {
|
|
336
484
|
return denied("read_file requires a path.");
|
|
@@ -351,10 +499,11 @@ export class WorkspaceTools {
|
|
|
351
499
|
const clippedContent = clip(content, 20_000);
|
|
352
500
|
return {
|
|
353
501
|
ok: true,
|
|
354
|
-
summary: `read ${
|
|
502
|
+
summary: `read ${normalizeRelative(this.root, absolutePath)}`,
|
|
355
503
|
content: clippedContent,
|
|
356
504
|
tool: "read_file",
|
|
357
|
-
category: toolSpecs.read_file.category
|
|
505
|
+
category: toolSpecs.read_file.category,
|
|
506
|
+
metadata: textContentMetadata(content)
|
|
358
507
|
};
|
|
359
508
|
}
|
|
360
509
|
async readRange(requestedPath, startLine, endLine) {
|
|
@@ -379,12 +528,12 @@ export class WorkspaceTools {
|
|
|
379
528
|
const numberedLines = selectedLines.map((line, index) => `${startLine + index}: ${line}`).join("\n");
|
|
380
529
|
return {
|
|
381
530
|
ok: true,
|
|
382
|
-
summary: `read ${
|
|
531
|
+
summary: `read ${normalizeRelative(this.root, absolutePath)}:${startLine}-${Math.min(endLine, lines.length)}`,
|
|
383
532
|
content: clip(numberedLines || "No lines in range.", 20_000),
|
|
384
533
|
tool: "read_range",
|
|
385
534
|
category: toolSpecs.read_range.category,
|
|
386
535
|
metadata: {
|
|
387
|
-
path:
|
|
536
|
+
path: normalizeRelative(this.root, absolutePath),
|
|
388
537
|
startLine,
|
|
389
538
|
endLine: Math.min(endLine, lines.length)
|
|
390
539
|
}
|
|
@@ -399,7 +548,7 @@ export class WorkspaceTools {
|
|
|
399
548
|
}
|
|
400
549
|
const absolutePath = await this.resolveReadPath(requestedPath);
|
|
401
550
|
const fileStat = await stat(absolutePath);
|
|
402
|
-
const relativePath =
|
|
551
|
+
const relativePath = normalizeRelative(this.root, absolutePath);
|
|
403
552
|
return {
|
|
404
553
|
ok: true,
|
|
405
554
|
summary: `inspected ${relativePath}`,
|
|
@@ -418,7 +567,7 @@ export class WorkspaceTools {
|
|
|
418
567
|
}
|
|
419
568
|
};
|
|
420
569
|
}
|
|
421
|
-
async inspectDocument(requestedPath) {
|
|
570
|
+
async inspectDocument(requestedPath, mode) {
|
|
422
571
|
if (!requestedPath) {
|
|
423
572
|
return denied("inspect_document requires a path.");
|
|
424
573
|
}
|
|
@@ -441,28 +590,84 @@ export class WorkspaceTools {
|
|
|
441
590
|
if (isLikelyTextFile(absolutePath)) {
|
|
442
591
|
return await this.readTextDocument(absolutePath);
|
|
443
592
|
}
|
|
593
|
+
const normalizedMode = normalizeDocumentInspectionMode(mode);
|
|
594
|
+
const wantsLocalOnly = normalizedMode === "local" || normalizedMode === "ocr";
|
|
444
595
|
if (extension === ".pdf") {
|
|
445
|
-
|
|
596
|
+
const pdfFallback = await extractPdfText(absolutePath, this.timeoutMs, this.signal);
|
|
597
|
+
if (wantsLocalOnly || !this.documentAnalyzer || hasUsefulExtractedText(pdfFallback)) {
|
|
598
|
+
return pdfFallback;
|
|
599
|
+
}
|
|
600
|
+
const providerResult = await this.analyzeDocumentWithProvider(absolutePath, "Analyze this PDF for PatchPilot. Extract readable text, describe structure, and note important visual or scanned content.");
|
|
601
|
+
if (providerResult.ok) {
|
|
602
|
+
return providerResult;
|
|
603
|
+
}
|
|
604
|
+
return mergeFallbackDocumentResult(providerResult, pdfFallback);
|
|
446
605
|
}
|
|
447
606
|
if (extension === ".docx") {
|
|
448
|
-
|
|
607
|
+
const docxFallback = await extractDocxText(absolutePath);
|
|
608
|
+
if (wantsLocalOnly || !this.documentAnalyzer || hasUsefulExtractedText(docxFallback)) {
|
|
609
|
+
return docxFallback;
|
|
610
|
+
}
|
|
611
|
+
const providerResult = await this.analyzeDocumentWithProvider(absolutePath, "Analyze this DOCX for PatchPilot. Extract the relevant text, headings, and document structure.");
|
|
612
|
+
if (providerResult.ok) {
|
|
613
|
+
return providerResult;
|
|
614
|
+
}
|
|
615
|
+
return mergeFallbackDocumentResult(providerResult, docxFallback);
|
|
616
|
+
}
|
|
617
|
+
if (extension === ".doc") {
|
|
618
|
+
const docFallback = await extractLegacyDocText(absolutePath, this.timeoutMs, this.signal);
|
|
619
|
+
if (wantsLocalOnly || !this.documentAnalyzer || hasUsefulExtractedText(docFallback)) {
|
|
620
|
+
return docFallback;
|
|
621
|
+
}
|
|
622
|
+
const providerResult = await this.analyzeDocumentWithProvider(absolutePath, "Analyze this Word document for PatchPilot. Extract the relevant text, headings, and document structure.");
|
|
623
|
+
if (providerResult.ok) {
|
|
624
|
+
return providerResult;
|
|
625
|
+
}
|
|
626
|
+
return mergeFallbackDocumentResult(providerResult, docFallback);
|
|
449
627
|
}
|
|
450
628
|
if (isImageFile(absolutePath)) {
|
|
451
|
-
return await inspectImageFile(absolutePath);
|
|
629
|
+
return await inspectImageFile(absolutePath, wantsLocalOnly ? undefined : this.documentAnalyzer, this.signal, normalizedMode, this.providerAnalysisTimeoutMs());
|
|
452
630
|
}
|
|
453
631
|
return denied(`inspect_document does not support ${extension || "this file type"} yet.`);
|
|
454
632
|
}
|
|
455
633
|
async readTextDocument(absolutePath) {
|
|
456
634
|
const content = await readFile(absolutePath, "utf8");
|
|
457
|
-
const
|
|
635
|
+
const rawRelativePath = path.relative(this.root, absolutePath);
|
|
636
|
+
const relativePath = normalizeRelative(this.root, absolutePath);
|
|
458
637
|
return {
|
|
459
638
|
ok: true,
|
|
460
|
-
summary: `inspected ${
|
|
639
|
+
summary: `inspected ${rawRelativePath.startsWith("..") || path.isAbsolute(rawRelativePath) ? absolutePath : relativePath}`,
|
|
461
640
|
content: clip(content, 20_000),
|
|
462
641
|
tool: "inspect_document",
|
|
463
|
-
category: toolSpecs.inspect_document.category
|
|
642
|
+
category: toolSpecs.inspect_document.category,
|
|
643
|
+
metadata: textContentMetadata(content)
|
|
464
644
|
};
|
|
465
645
|
}
|
|
646
|
+
async analyzeDocumentWithProvider(absolutePath, prompt) {
|
|
647
|
+
if (!this.documentAnalyzer) {
|
|
648
|
+
return denied("inspect_document has no provider document analyzer configured.", "inspect_document");
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const analysis = await runDocumentAnalyzer(this.documentAnalyzer, {
|
|
652
|
+
path: absolutePath,
|
|
653
|
+
prompt,
|
|
654
|
+
signal: this.signal
|
|
655
|
+
}, this.providerAnalysisTimeoutMs());
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
summary: `analyzed ${path.basename(absolutePath)} with provider file input`,
|
|
659
|
+
content: clip(analysis, 20_000),
|
|
660
|
+
tool: "inspect_document",
|
|
661
|
+
category: toolSpecs.inspect_document.category
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
return denied(`provider file analysis failed for ${path.basename(absolutePath)}: ${error instanceof Error ? error.message : String(error)}`, "inspect_document");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
providerAnalysisTimeoutMs() {
|
|
669
|
+
return Math.min(this.timeoutMs, 90_000);
|
|
670
|
+
}
|
|
466
671
|
async memoryRemember(content, tags) {
|
|
467
672
|
if (!this.memoryEnabled) {
|
|
468
673
|
return denied("memory_remember requires /experimental memory.", "memory_remember");
|
|
@@ -552,25 +757,170 @@ export class WorkspaceTools {
|
|
|
552
757
|
if (isSensitivePath(requestedPath)) {
|
|
553
758
|
return denied(`write_file denied sensitive path: ${requestedPath}`);
|
|
554
759
|
}
|
|
760
|
+
const absolutePath = await this.resolveWritePath(requestedPath);
|
|
761
|
+
const normalized = normalizePossiblyEscapedFileContent(content, absolutePath);
|
|
555
762
|
if (!this.allowWrite) {
|
|
556
763
|
const approval = await this.requestApproval("write_file", "write", {
|
|
557
764
|
path: requestedPath,
|
|
558
|
-
contentLength: content.length
|
|
559
|
-
}, `Write ${requestedPath} (${content.length} characters).`);
|
|
765
|
+
contentLength: normalized.content.length
|
|
766
|
+
}, `Write ${requestedPath} (${normalized.content.length} characters).`);
|
|
560
767
|
if (approval.decision === "deny") {
|
|
561
768
|
return denied("write_file denied by permission policy. Restart with --apply or approve the request in build mode.", "write_file", approval);
|
|
562
769
|
}
|
|
563
770
|
}
|
|
564
|
-
const absolutePath = await this.resolveWritePath(requestedPath);
|
|
565
771
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
566
|
-
await writeFile(absolutePath, content, "utf8");
|
|
772
|
+
await writeFile(absolutePath, normalized.content, "utf8");
|
|
567
773
|
return {
|
|
568
774
|
ok: true,
|
|
569
|
-
summary: `wrote ${
|
|
570
|
-
content: `Wrote ${content.length} characters
|
|
775
|
+
summary: `wrote ${normalizeRelative(this.root, absolutePath)}`,
|
|
776
|
+
content: `Wrote ${normalized.content.length} characters.${normalized.normalized ? " Normalized escaped newlines before writing." : ""}`,
|
|
571
777
|
tool: "write_file",
|
|
572
778
|
category: toolSpecs.write_file.category,
|
|
573
|
-
preview: `Write ${
|
|
779
|
+
preview: `Write ${normalizeRelative(this.root, absolutePath)}`,
|
|
780
|
+
metadata: {
|
|
781
|
+
normalizedEscapedContent: normalized.normalized
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
async createPdf(requestedPath, content, title) {
|
|
786
|
+
if (!requestedPath) {
|
|
787
|
+
return denied("create_pdf requires a path.", "create_pdf");
|
|
788
|
+
}
|
|
789
|
+
const targetPath = requestedPath.toLowerCase().endsWith(".pdf") ? requestedPath : `${requestedPath}.pdf`;
|
|
790
|
+
const approval = await this.requestWriteApproval("create_pdf", targetPath, content.length, "Create PDF");
|
|
791
|
+
if (approval) {
|
|
792
|
+
return approval;
|
|
793
|
+
}
|
|
794
|
+
const absolutePath = await this.resolveWritePath(targetPath);
|
|
795
|
+
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
796
|
+
const pdf = createSimplePdf(content, title || path.basename(targetPath, ".pdf"));
|
|
797
|
+
await writeFile(absolutePath, pdf);
|
|
798
|
+
return {
|
|
799
|
+
ok: true,
|
|
800
|
+
summary: `created PDF ${normalizeRelative(this.root, absolutePath)}`,
|
|
801
|
+
content: `Created ${pdf.length} byte PDF from ${content.length} characters.`,
|
|
802
|
+
tool: "create_pdf",
|
|
803
|
+
category: toolSpecs.create_pdf.category,
|
|
804
|
+
preview: `Create PDF ${normalizeRelative(this.root, absolutePath)}`
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
async createDocx(requestedPath, content, title) {
|
|
808
|
+
if (!requestedPath) {
|
|
809
|
+
return denied("create_docx requires a path.", "create_docx");
|
|
810
|
+
}
|
|
811
|
+
const targetPath = requestedPath.toLowerCase().endsWith(".docx") ? requestedPath : `${requestedPath}.docx`;
|
|
812
|
+
const approval = await this.requestWriteApproval("create_docx", targetPath, content.length, "Create DOCX");
|
|
813
|
+
if (approval) {
|
|
814
|
+
return approval;
|
|
815
|
+
}
|
|
816
|
+
const absolutePath = await this.resolveWritePath(targetPath);
|
|
817
|
+
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
818
|
+
const docx = createSimpleDocx(content, title);
|
|
819
|
+
await writeFile(absolutePath, docx);
|
|
820
|
+
return {
|
|
821
|
+
ok: true,
|
|
822
|
+
summary: `created DOCX ${normalizeRelative(this.root, absolutePath)}`,
|
|
823
|
+
content: `Created ${docx.length} byte DOCX from ${content.length} characters.`,
|
|
824
|
+
tool: "create_docx",
|
|
825
|
+
category: toolSpecs.create_docx.category,
|
|
826
|
+
preview: `Create DOCX ${normalizeRelative(this.root, absolutePath)}`
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
async requestWriteApproval(tool, requestedPath, contentLength, action) {
|
|
830
|
+
if (isPlaceholderPath(requestedPath)) {
|
|
831
|
+
return denied(`${tool} denied placeholder path: ${requestedPath}`, tool);
|
|
832
|
+
}
|
|
833
|
+
if (isSensitivePath(requestedPath)) {
|
|
834
|
+
return denied(`${tool} denied sensitive path: ${requestedPath}`, tool);
|
|
835
|
+
}
|
|
836
|
+
if (this.allowWrite) {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
const approval = await this.requestApproval(tool, "write", {
|
|
840
|
+
path: requestedPath,
|
|
841
|
+
contentLength
|
|
842
|
+
}, `${action} ${requestedPath} (${contentLength} characters).`);
|
|
843
|
+
if (approval.decision === "deny") {
|
|
844
|
+
return denied(`${tool} denied by permission policy. Restart with --apply or approve the request in build mode.`, tool, approval);
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
async editFile(requestedPath, findText, replaceText, startLine, endLine, replacementText, expectedText) {
|
|
849
|
+
if (!requestedPath) {
|
|
850
|
+
return denied("edit_file requires a path.", "edit_file");
|
|
851
|
+
}
|
|
852
|
+
if (isPlaceholderPath(requestedPath)) {
|
|
853
|
+
return denied(`edit_file denied placeholder path: ${requestedPath}`, "edit_file");
|
|
854
|
+
}
|
|
855
|
+
if (isSensitivePath(requestedPath)) {
|
|
856
|
+
return denied(`edit_file denied sensitive path: ${requestedPath}`, "edit_file");
|
|
857
|
+
}
|
|
858
|
+
const usesLineRange = startLine > 0 || endLine > 0;
|
|
859
|
+
const usesFindReplace = findText.length > 0;
|
|
860
|
+
if (usesLineRange === usesFindReplace) {
|
|
861
|
+
return denied("edit_file requires either find/replace or startLine/endLine/replacement.", "edit_file");
|
|
862
|
+
}
|
|
863
|
+
if (usesLineRange && (startLine < 1 || endLine < startLine)) {
|
|
864
|
+
return denied("edit_file requires 1-based startLine/endLine values.", "edit_file");
|
|
865
|
+
}
|
|
866
|
+
const absolutePath = await this.resolveWritePath(requestedPath);
|
|
867
|
+
if (!isLikelyTextFile(absolutePath)) {
|
|
868
|
+
return denied(`edit_file supports text/code files. Use write_file only when replacing the full ${path.extname(absolutePath) || "file"} file is intentional.`, "edit_file");
|
|
869
|
+
}
|
|
870
|
+
const originalContent = await readFile(absolutePath, "utf8").catch((error) => {
|
|
871
|
+
throw new Error(`file not found or unreadable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
|
|
872
|
+
});
|
|
873
|
+
const normalizedReplaceText = normalizePossiblyEscapedFileContent(replaceText, absolutePath).content;
|
|
874
|
+
const normalizedReplacementText = normalizePossiblyEscapedFileContent(replacementText, absolutePath).content;
|
|
875
|
+
const normalizedExpectedText = expectedText === undefined ? undefined : normalizePossiblyEscapedFileContent(expectedText, absolutePath).content;
|
|
876
|
+
let nextContent = originalContent;
|
|
877
|
+
let editSummary = "";
|
|
878
|
+
if (usesFindReplace) {
|
|
879
|
+
const matches = countOccurrences(originalContent, findText);
|
|
880
|
+
if (matches !== 1) {
|
|
881
|
+
return denied(`edit_file find text must match exactly once; found ${matches} matches.`, "edit_file");
|
|
882
|
+
}
|
|
883
|
+
nextContent = originalContent.replace(findText, normalizedReplaceText);
|
|
884
|
+
editSummary = `replaced 1 match in ${normalizeRelative(this.root, absolutePath)}`;
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
const lines = originalContent.split(/\r?\n/);
|
|
888
|
+
if (endLine > lines.length) {
|
|
889
|
+
return denied(`edit_file line range exceeds file length (${lines.length} lines).`, "edit_file");
|
|
890
|
+
}
|
|
891
|
+
const replacementLines = normalizedReplacementText.split(/\r?\n/);
|
|
892
|
+
const currentRange = lines.slice(startLine - 1, endLine).join("\n");
|
|
893
|
+
if (normalizedExpectedText !== undefined && currentRange !== normalizedExpectedText) {
|
|
894
|
+
return denied("edit_file expected content did not match the current line range.", "edit_file");
|
|
895
|
+
}
|
|
896
|
+
lines.splice(startLine - 1, endLine - startLine + 1, ...replacementLines);
|
|
897
|
+
nextContent = lines.join("\n");
|
|
898
|
+
editSummary = `replaced lines ${startLine}-${endLine} in ${normalizeRelative(this.root, absolutePath)}`;
|
|
899
|
+
}
|
|
900
|
+
if (nextContent === originalContent) {
|
|
901
|
+
return denied("edit_file produced no changes.", "edit_file");
|
|
902
|
+
}
|
|
903
|
+
if (!this.allowWrite) {
|
|
904
|
+
const approval = await this.requestApproval("edit_file", "write", {
|
|
905
|
+
path: requestedPath,
|
|
906
|
+
startLine: usesLineRange ? startLine : undefined,
|
|
907
|
+
endLine: usesLineRange ? endLine : undefined,
|
|
908
|
+
findLength: usesFindReplace ? findText.length : undefined,
|
|
909
|
+
expectedLength: normalizedExpectedText?.length,
|
|
910
|
+
replacementLength: usesLineRange ? normalizedReplacementText.length : normalizedReplaceText.length
|
|
911
|
+
}, `Edit ${requestedPath}: ${editSummary}`);
|
|
912
|
+
if (approval.decision === "deny") {
|
|
913
|
+
return denied("edit_file denied by permission policy. Restart with --apply or approve the request in build mode.", "edit_file", approval);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
await writeFile(absolutePath, nextContent, "utf8");
|
|
917
|
+
return {
|
|
918
|
+
ok: true,
|
|
919
|
+
summary: editSummary,
|
|
920
|
+
content: `Edited ${normalizeRelative(this.root, absolutePath)}.`,
|
|
921
|
+
tool: "edit_file",
|
|
922
|
+
category: toolSpecs.edit_file.category,
|
|
923
|
+
preview: `Edit ${normalizeRelative(this.root, absolutePath)}`
|
|
574
924
|
};
|
|
575
925
|
}
|
|
576
926
|
async gitStatus() {
|
|
@@ -610,6 +960,48 @@ export class WorkspaceTools {
|
|
|
610
960
|
category: toolSpecs.git_diff.category
|
|
611
961
|
};
|
|
612
962
|
}
|
|
963
|
+
async gitLog(limit) {
|
|
964
|
+
const normalizedLimit = Math.max(1, Math.min(50, Math.floor(limit || 8)));
|
|
965
|
+
const { stdout } = await execFileAsync("git", ["log", "--oneline", "--decorate", `--max-count=${normalizedLimit}`], {
|
|
966
|
+
cwd: this.root,
|
|
967
|
+
timeout: Math.min(this.timeoutMs, 8000),
|
|
968
|
+
maxBuffer: 200_000,
|
|
969
|
+
signal: this.signal,
|
|
970
|
+
windowsHide: true
|
|
971
|
+
});
|
|
972
|
+
return {
|
|
973
|
+
ok: true,
|
|
974
|
+
summary: `read ${normalizedLimit} git commit${normalizedLimit === 1 ? "" : "s"}`,
|
|
975
|
+
content: stdout.trim() || "No commits found.",
|
|
976
|
+
tool: "git_log",
|
|
977
|
+
category: toolSpecs.git_log.category
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
async gitShow(revision, requestedPath) {
|
|
981
|
+
const normalizedRevision = revision.trim() || "HEAD";
|
|
982
|
+
if (!/^[A-Za-z0-9_./:@{}^~+-]+$/.test(normalizedRevision)) {
|
|
983
|
+
return denied("git_show revision contains unsupported characters.", "git_show");
|
|
984
|
+
}
|
|
985
|
+
const args = ["show", "--stat", "--oneline", "--decorate", "--no-ext-diff", normalizedRevision, "--"];
|
|
986
|
+
if (requestedPath.trim()) {
|
|
987
|
+
const absolutePath = this.resolveInsideWorkspace(requestedPath);
|
|
988
|
+
args.push(path.relative(this.root, absolutePath));
|
|
989
|
+
}
|
|
990
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
991
|
+
cwd: this.root,
|
|
992
|
+
timeout: Math.min(this.timeoutMs, 8000),
|
|
993
|
+
maxBuffer: 500_000,
|
|
994
|
+
signal: this.signal,
|
|
995
|
+
windowsHide: true
|
|
996
|
+
});
|
|
997
|
+
return {
|
|
998
|
+
ok: true,
|
|
999
|
+
summary: `read git revision ${normalizedRevision}`,
|
|
1000
|
+
content: clip(stdout.trim() || "No revision output.", 20_000),
|
|
1001
|
+
tool: "git_show",
|
|
1002
|
+
category: toolSpecs.git_show.category
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
613
1005
|
async listChangedFiles() {
|
|
614
1006
|
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
|
|
615
1007
|
cwd: this.root,
|
|
@@ -644,13 +1036,95 @@ export class WorkspaceTools {
|
|
|
644
1036
|
category: toolSpecs.list_scripts.category
|
|
645
1037
|
};
|
|
646
1038
|
}
|
|
1039
|
+
async repoOverview() {
|
|
1040
|
+
const packageJson = await this.readPackageJsonObject().catch(() => ({}));
|
|
1041
|
+
const rootRealPath = await this.rootRealPath;
|
|
1042
|
+
const files = await walkFiles(this.root, this.root, rootRealPath, 1, 80).catch(() => []);
|
|
1043
|
+
const gitStatus = await execFileAsync("git", ["status", "--short", "--branch"], {
|
|
1044
|
+
cwd: this.root,
|
|
1045
|
+
timeout: Math.min(this.timeoutMs, 5000),
|
|
1046
|
+
maxBuffer: 100_000,
|
|
1047
|
+
signal: this.signal,
|
|
1048
|
+
windowsHide: true
|
|
1049
|
+
}).then((result) => result.stdout.trim()).catch(() => "No git repository detected.");
|
|
1050
|
+
const scripts = Object.keys(readStringRecord(packageJson.scripts)).sort();
|
|
1051
|
+
const dependencies = Object.keys(readStringRecord(packageJson.dependencies)).length;
|
|
1052
|
+
const devDependencies = Object.keys(readStringRecord(packageJson.devDependencies)).length;
|
|
1053
|
+
return {
|
|
1054
|
+
ok: true,
|
|
1055
|
+
summary: "read repository overview",
|
|
1056
|
+
content: [
|
|
1057
|
+
`name: ${typeof packageJson.name === "string" ? packageJson.name : path.basename(this.root)}`,
|
|
1058
|
+
`version: ${typeof packageJson.version === "string" ? packageJson.version : "unknown"}`,
|
|
1059
|
+
`description: ${typeof packageJson.description === "string" ? packageJson.description : "none"}`,
|
|
1060
|
+
`scripts: ${scripts.join(", ") || "none"}`,
|
|
1061
|
+
`dependencies: ${dependencies} runtime, ${devDependencies} dev`,
|
|
1062
|
+
"",
|
|
1063
|
+
"git:",
|
|
1064
|
+
gitStatus || "clean",
|
|
1065
|
+
"",
|
|
1066
|
+
"top-level files:",
|
|
1067
|
+
files.join("\n") || "No files found."
|
|
1068
|
+
].join("\n"),
|
|
1069
|
+
tool: "repo_overview",
|
|
1070
|
+
category: toolSpecs.repo_overview.category
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
async testList() {
|
|
1074
|
+
const packageJson = await this.readPackageJsonObject().catch(() => ({}));
|
|
1075
|
+
const scripts = Object.entries(readStringRecord(packageJson.scripts))
|
|
1076
|
+
.filter(([name, command]) => /test|spec|vitest|jest|playwright|check/i.test(`${name} ${command}`))
|
|
1077
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
1078
|
+
const files = await walkFiles(this.root, this.root, await this.rootRealPath, 8, 500).catch(() => []);
|
|
1079
|
+
const testFiles = files.filter((filePath) => /(^|\/)(__tests__|tests?|specs?)\/|[.-](test|spec)\.[cm]?[jt]sx?$|\.test\./i.test(filePath));
|
|
1080
|
+
return {
|
|
1081
|
+
ok: true,
|
|
1082
|
+
summary: `listed ${testFiles.length} likely test file${testFiles.length === 1 ? "" : "s"}`,
|
|
1083
|
+
content: [
|
|
1084
|
+
"test scripts:",
|
|
1085
|
+
scripts.map(([name, command]) => `${name}: ${command}`).join("\n") || "No test-related scripts found.",
|
|
1086
|
+
"",
|
|
1087
|
+
"test files:",
|
|
1088
|
+
testFiles.slice(0, 120).join("\n") || "No likely test files found."
|
|
1089
|
+
].join("\n"),
|
|
1090
|
+
tool: "test_list",
|
|
1091
|
+
category: toolSpecs.test_list.category
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
async dependencyTree() {
|
|
1095
|
+
const packageJson = await this.readPackageJsonObject();
|
|
1096
|
+
const sections = [
|
|
1097
|
+
["dependencies", readStringRecord(packageJson.dependencies)],
|
|
1098
|
+
["devDependencies", readStringRecord(packageJson.devDependencies)],
|
|
1099
|
+
["peerDependencies", readStringRecord(packageJson.peerDependencies)],
|
|
1100
|
+
["optionalDependencies", readStringRecord(packageJson.optionalDependencies)]
|
|
1101
|
+
];
|
|
1102
|
+
const content = sections
|
|
1103
|
+
.map(([sectionName, dependencies]) => {
|
|
1104
|
+
const entries = Object.entries(dependencies).sort(([left], [right]) => left.localeCompare(right));
|
|
1105
|
+
return [`${sectionName}:`, entries.map(([name, version]) => `- ${name}@${version}`).join("\n") || "- none"].join("\n");
|
|
1106
|
+
})
|
|
1107
|
+
.join("\n\n");
|
|
1108
|
+
return {
|
|
1109
|
+
ok: true,
|
|
1110
|
+
summary: "read dependency tree",
|
|
1111
|
+
content,
|
|
1112
|
+
tool: "dependency_tree",
|
|
1113
|
+
category: toolSpecs.dependency_tree.category
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
647
1116
|
async applyPatch(patchContent) {
|
|
648
1117
|
if (!patchContent.trim()) {
|
|
649
1118
|
return denied("apply_patch requires a unified patch.", "apply_patch");
|
|
650
1119
|
}
|
|
1120
|
+
const validationError = await this.validatePatchTargets(patchContent);
|
|
1121
|
+
if (validationError) {
|
|
1122
|
+
return denied(validationError, "apply_patch");
|
|
1123
|
+
}
|
|
651
1124
|
if (!this.allowWrite) {
|
|
652
1125
|
const approval = await this.requestApproval("apply_patch", "write", {
|
|
653
|
-
patch: clip(patchContent, 1200)
|
|
1126
|
+
patch: clip(patchContent, 1200),
|
|
1127
|
+
patchHash: stableHash(patchContent)
|
|
654
1128
|
}, previewPatch(patchContent));
|
|
655
1129
|
if (approval.decision === "deny") {
|
|
656
1130
|
return denied("apply_patch denied by permission policy.", "apply_patch", approval);
|
|
@@ -667,22 +1141,33 @@ export class WorkspaceTools {
|
|
|
667
1141
|
};
|
|
668
1142
|
}
|
|
669
1143
|
async runScript(scriptName) {
|
|
1144
|
+
return await this.runPackageScript("run_script", scriptName);
|
|
1145
|
+
}
|
|
1146
|
+
async runTests() {
|
|
1147
|
+
return await this.runPackageScript("run_tests", "test");
|
|
1148
|
+
}
|
|
1149
|
+
async runPackageScript(tool, scriptName) {
|
|
670
1150
|
const normalizedScript = scriptName.trim();
|
|
671
1151
|
if (!/^[\w:.-]+$/.test(normalizedScript)) {
|
|
672
|
-
return denied(
|
|
1152
|
+
return denied(`${tool} requires a package script name such as test or build.`, tool);
|
|
673
1153
|
}
|
|
674
1154
|
const scripts = await this.readPackageScripts();
|
|
675
1155
|
if (!scripts[normalizedScript]) {
|
|
676
|
-
return denied(`package script not found: ${normalizedScript}`,
|
|
1156
|
+
return denied(`package script not found: ${normalizedScript}`, tool);
|
|
677
1157
|
}
|
|
678
|
-
const
|
|
1158
|
+
const scriptCommands = collectPackageScriptCommands(scripts, normalizedScript);
|
|
1159
|
+
const scriptSafetyError = validatePackageScriptCommands(scriptCommands, this.root);
|
|
1160
|
+
if (scriptSafetyError) {
|
|
1161
|
+
return denied(`${tool} denied package script before approval. ${scriptSafetyError}`, tool);
|
|
1162
|
+
}
|
|
1163
|
+
const approvalCommand = scriptCommands.map((entry) => `${entry.name}: ${entry.command}`).join("\n");
|
|
679
1164
|
if (!this.allowShell) {
|
|
680
|
-
const approval = await this.requestApproval(
|
|
1165
|
+
const approval = await this.requestApproval(tool, "shell", {
|
|
681
1166
|
script: normalizedScript,
|
|
682
|
-
command:
|
|
683
|
-
},
|
|
1167
|
+
command: approvalCommand
|
|
1168
|
+
}, previewPackageScriptSequence(normalizedScript, scriptCommands, this.root));
|
|
684
1169
|
if (approval.decision === "deny") {
|
|
685
|
-
return denied(
|
|
1170
|
+
return denied(`${tool} denied by permission policy.`, tool, approval);
|
|
686
1171
|
}
|
|
687
1172
|
}
|
|
688
1173
|
const output = await runCommand(`npm run ${normalizedScript}`, this.root, this.timeoutMs, this.signal);
|
|
@@ -690,36 +1175,34 @@ export class WorkspaceTools {
|
|
|
690
1175
|
ok: output.exitCode === 0,
|
|
691
1176
|
summary: `npm run ${normalizedScript} exited ${output.exitCode}`,
|
|
692
1177
|
content: clip(output.output, 20_000),
|
|
693
|
-
tool
|
|
694
|
-
category: toolSpecs.
|
|
695
|
-
preview:
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
async runTests() {
|
|
699
|
-
const scripts = await this.readPackageScripts();
|
|
700
|
-
if (!scripts.test) {
|
|
701
|
-
return denied("No package test script found.", "run_tests");
|
|
702
|
-
}
|
|
703
|
-
const result = await this.runScript("test");
|
|
704
|
-
return {
|
|
705
|
-
...result,
|
|
706
|
-
tool: "run_tests",
|
|
707
|
-
category: toolSpecs.run_tests.category,
|
|
708
|
-
preview: "npm test"
|
|
1178
|
+
tool,
|
|
1179
|
+
category: toolSpecs[tool].category,
|
|
1180
|
+
preview: previewPackageScriptSequence(normalizedScript, scriptCommands, this.root)
|
|
709
1181
|
};
|
|
710
1182
|
}
|
|
711
1183
|
async runShell(command) {
|
|
712
1184
|
if (!command.trim()) {
|
|
713
1185
|
return denied("run_shell requires a command.");
|
|
714
1186
|
}
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
1187
|
+
const shellSafety = validateShellCommand(command, this.root, {
|
|
1188
|
+
allowMetacharacters: this.allowShellMetacharacters
|
|
1189
|
+
});
|
|
1190
|
+
if (shellSafety.error) {
|
|
1191
|
+
return denied(`run_shell denied. ${shellSafety.error}`);
|
|
718
1192
|
}
|
|
719
|
-
|
|
1193
|
+
const shellPathError = await this.validateShellPathArguments(command);
|
|
1194
|
+
if (shellPathError) {
|
|
1195
|
+
return denied(`run_shell denied. ${shellPathError}`);
|
|
1196
|
+
}
|
|
1197
|
+
if (!this.allowShell || shellSafety.requiresApprovalReason) {
|
|
720
1198
|
const approval = await this.requestApproval("run_shell", "shell", {
|
|
721
|
-
command
|
|
722
|
-
|
|
1199
|
+
command,
|
|
1200
|
+
...(shellSafety.requiresApprovalReason ? { approvalReason: shellSafety.requiresApprovalReason } : {})
|
|
1201
|
+
}, shellSafety.requiresApprovalReason
|
|
1202
|
+
? `High-risk shell command (${shellSafety.requiresApprovalReason}): ${command}`
|
|
1203
|
+
: `Run shell command: ${command}`, {
|
|
1204
|
+
bypassable: !shellSafety.requiresApprovalReason
|
|
1205
|
+
});
|
|
723
1206
|
if (approval.decision === "deny") {
|
|
724
1207
|
return denied("run_shell denied by permission policy.", "run_shell", approval);
|
|
725
1208
|
}
|
|
@@ -735,11 +1218,14 @@ export class WorkspaceTools {
|
|
|
735
1218
|
};
|
|
736
1219
|
}
|
|
737
1220
|
async readPackageScripts() {
|
|
738
|
-
const
|
|
739
|
-
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
1221
|
+
const packageJson = await this.readPackageJsonObject();
|
|
740
1222
|
return Object.fromEntries(Object.entries(packageJson.scripts ?? {}).filter((entry) => typeof entry[1] === "string"));
|
|
741
1223
|
}
|
|
742
|
-
async
|
|
1224
|
+
async readPackageJsonObject() {
|
|
1225
|
+
const packageJsonPath = await this.resolveReadPath("package.json");
|
|
1226
|
+
return JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
1227
|
+
}
|
|
1228
|
+
async requestApproval(tool, permission, args, preview, options = {}) {
|
|
743
1229
|
const spec = getToolSpec(tool);
|
|
744
1230
|
const request = {
|
|
745
1231
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
@@ -747,9 +1233,11 @@ export class WorkspaceTools {
|
|
|
747
1233
|
permission,
|
|
748
1234
|
risk: spec.risk,
|
|
749
1235
|
preview,
|
|
750
|
-
arguments: args
|
|
1236
|
+
arguments: args,
|
|
1237
|
+
bypassable: options.bypassable
|
|
751
1238
|
};
|
|
752
|
-
|
|
1239
|
+
const approvalKey = approvalScopeKey(tool, permission, args);
|
|
1240
|
+
if (this.sessionApprovals.has(approvalKey)) {
|
|
753
1241
|
return {
|
|
754
1242
|
request,
|
|
755
1243
|
decision: "allow_session"
|
|
@@ -763,7 +1251,7 @@ export class WorkspaceTools {
|
|
|
763
1251
|
}
|
|
764
1252
|
const decision = await this.approvalHandler(request);
|
|
765
1253
|
if (decision === "allow_session") {
|
|
766
|
-
this.sessionApprovals.add(
|
|
1254
|
+
this.sessionApprovals.add(approvalKey);
|
|
767
1255
|
}
|
|
768
1256
|
return {
|
|
769
1257
|
request,
|
|
@@ -775,7 +1263,7 @@ export class WorkspaceTools {
|
|
|
775
1263
|
const resolvedPath = await realpath(absolutePath).catch((error) => {
|
|
776
1264
|
throw new Error(`file not found or unreadable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
|
|
777
1265
|
});
|
|
778
|
-
await
|
|
1266
|
+
await this.assertSafeResolvedWorkspacePath(resolvedPath, requestedPath);
|
|
779
1267
|
return resolvedPath;
|
|
780
1268
|
}
|
|
781
1269
|
async resolveDocumentPath(requestedPath) {
|
|
@@ -800,7 +1288,7 @@ export class WorkspaceTools {
|
|
|
800
1288
|
throw new Error(`Path escapes workspace: ${requestedPath}. Enable /experimental file-analysis to inspect external files.`);
|
|
801
1289
|
}
|
|
802
1290
|
const extension = path.extname(trimmedPath).toLowerCase();
|
|
803
|
-
if (!isLikelyTextFile(trimmedPath) && extension !== ".pdf" && extension !== ".docx" && !isImageFile(trimmedPath)) {
|
|
1291
|
+
if (!isLikelyTextFile(trimmedPath) && extension !== ".pdf" && extension !== ".docx" && extension !== ".doc" && !isImageFile(trimmedPath)) {
|
|
804
1292
|
throw new Error(`external file analysis does not support ${extension || "this file type"} yet.`);
|
|
805
1293
|
}
|
|
806
1294
|
const resolvedPath = await realpath(trimmedPath).catch((error) => {
|
|
@@ -820,15 +1308,74 @@ export class WorkspaceTools {
|
|
|
820
1308
|
const existingParent = await findNearestExistingParent(absolutePath);
|
|
821
1309
|
const parentRealPath = await realpath(existingParent);
|
|
822
1310
|
await assertInsideWorkspace(rootRealPath, parentRealPath, requestedPath);
|
|
1311
|
+
assertNotSensitiveResolvedPath(rootRealPath, parentRealPath, requestedPath);
|
|
823
1312
|
const targetStat = await lstat(absolutePath).catch(() => null);
|
|
824
1313
|
if (targetStat) {
|
|
825
1314
|
const resolvedTargetPath = await realpath(absolutePath).catch((error) => {
|
|
826
1315
|
throw new Error(`file not writable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
|
|
827
1316
|
});
|
|
828
|
-
await
|
|
1317
|
+
await this.assertSafeResolvedWorkspacePath(resolvedTargetPath, requestedPath);
|
|
829
1318
|
}
|
|
830
1319
|
return absolutePath;
|
|
831
1320
|
}
|
|
1321
|
+
async assertSafeResolvedWorkspacePath(resolvedPath, requestedPath) {
|
|
1322
|
+
const rootRealPath = await this.rootRealPath;
|
|
1323
|
+
await assertInsideWorkspace(rootRealPath, resolvedPath, requestedPath);
|
|
1324
|
+
assertNotSensitiveResolvedPath(rootRealPath, resolvedPath, requestedPath);
|
|
1325
|
+
}
|
|
1326
|
+
async validatePatchTargets(patchContent) {
|
|
1327
|
+
if (patchCreatesSymlink(patchContent)) {
|
|
1328
|
+
return "apply_patch denied symlink patches.";
|
|
1329
|
+
}
|
|
1330
|
+
for (const targetPath of extractPatchTargetPaths(patchContent)) {
|
|
1331
|
+
if (isPlaceholderPath(targetPath)) {
|
|
1332
|
+
return `apply_patch denied placeholder path: ${targetPath}`;
|
|
1333
|
+
}
|
|
1334
|
+
if (isSensitivePath(targetPath) || targetPath === ".patchpilot" || targetPath.startsWith(".patchpilot/")) {
|
|
1335
|
+
return `apply_patch denied sensitive path: ${targetPath}`;
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
const absolutePath = this.resolveInsideWorkspace(targetPath);
|
|
1339
|
+
const existingPath = await realpath(absolutePath).catch(() => null);
|
|
1340
|
+
if (existingPath) {
|
|
1341
|
+
await this.assertSafeResolvedWorkspacePath(existingPath, targetPath);
|
|
1342
|
+
}
|
|
1343
|
+
else {
|
|
1344
|
+
const parentRealPath = await realpath(await findNearestExistingParent(absolutePath));
|
|
1345
|
+
await assertInsideWorkspace(await this.rootRealPath, parentRealPath, targetPath);
|
|
1346
|
+
assertNotSensitiveResolvedPath(await this.rootRealPath, parentRealPath, targetPath);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
catch (error) {
|
|
1350
|
+
return error instanceof Error ? error.message : String(error);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
async validateShellPathArguments(command) {
|
|
1356
|
+
const tokens = tokenizeShellCommand(command);
|
|
1357
|
+
for (const segment of splitPipeline(tokens)) {
|
|
1358
|
+
for (const token of segment.slice(1)) {
|
|
1359
|
+
const normalizedToken = stripQuotes(token);
|
|
1360
|
+
if (!normalizedToken || shellOperatorTokens.has(normalizedToken) || normalizedToken.startsWith("-")) {
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
const absolutePath = toAbsoluteShellPath(normalizedToken) ?? path.resolve(this.root, normalizedToken);
|
|
1364
|
+
const existingPath = await lstat(absolutePath).catch(() => null);
|
|
1365
|
+
if (!existingPath) {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
try {
|
|
1369
|
+
const resolvedPath = await realpath(absolutePath);
|
|
1370
|
+
await this.assertSafeResolvedWorkspacePath(resolvedPath, normalizedToken);
|
|
1371
|
+
}
|
|
1372
|
+
catch (error) {
|
|
1373
|
+
return error instanceof Error ? error.message : String(error);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
832
1379
|
}
|
|
833
1380
|
async function walkFiles(startPath, workspaceRoot, workspaceRealRoot, maxDepth, maxEntries) {
|
|
834
1381
|
const results = [];
|
|
@@ -972,6 +1519,15 @@ async function assertInsideWorkspace(workspaceRealRoot, candidatePath, requested
|
|
|
972
1519
|
throw new Error(`Path escapes workspace: ${requestedPath}`);
|
|
973
1520
|
}
|
|
974
1521
|
}
|
|
1522
|
+
function assertNotSensitiveResolvedPath(workspaceRealRoot, candidatePath, requestedPath) {
|
|
1523
|
+
const relativePath = normalizeSlashPath(path.relative(workspaceRealRoot, candidatePath));
|
|
1524
|
+
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
if (isSensitivePath(relativePath) || relativePath === ".patchpilot" || relativePath.startsWith(".patchpilot/")) {
|
|
1528
|
+
throw new Error(`Path resolves to sensitive workspace path: ${requestedPath}`);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
975
1531
|
async function findNearestExistingParent(absolutePath) {
|
|
976
1532
|
let currentPath = path.dirname(absolutePath);
|
|
977
1533
|
while (true) {
|
|
@@ -1056,6 +1612,73 @@ function runGitApply(patchContent, cwd, timeoutMs, signal) {
|
|
|
1056
1612
|
function readString(value, fallback) {
|
|
1057
1613
|
return typeof value === "string" ? value : fallback;
|
|
1058
1614
|
}
|
|
1615
|
+
function readOptionalString(value) {
|
|
1616
|
+
return typeof value === "string" ? value : undefined;
|
|
1617
|
+
}
|
|
1618
|
+
function countOccurrences(value, needle) {
|
|
1619
|
+
if (!needle) {
|
|
1620
|
+
return 0;
|
|
1621
|
+
}
|
|
1622
|
+
let count = 0;
|
|
1623
|
+
let index = 0;
|
|
1624
|
+
while (true) {
|
|
1625
|
+
index = value.indexOf(needle, index);
|
|
1626
|
+
if (index === -1) {
|
|
1627
|
+
return count;
|
|
1628
|
+
}
|
|
1629
|
+
count += 1;
|
|
1630
|
+
index += needle.length;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
function normalizePossiblyEscapedFileContent(content, filePath) {
|
|
1634
|
+
if (!shouldDecodeEscapedFileContent(content, filePath)) {
|
|
1635
|
+
return {
|
|
1636
|
+
content,
|
|
1637
|
+
normalized: false
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
const decoded = decodeCommonJsonStringEscapes(content);
|
|
1641
|
+
return {
|
|
1642
|
+
content: decoded,
|
|
1643
|
+
normalized: decoded !== content
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
function shouldDecodeEscapedFileContent(content, filePath) {
|
|
1647
|
+
const escapedNewlines = countOccurrences(content, "\\n");
|
|
1648
|
+
if (escapedNewlines === 0) {
|
|
1649
|
+
return false;
|
|
1650
|
+
}
|
|
1651
|
+
const realNewlines = countOccurrences(content, "\n");
|
|
1652
|
+
if (realNewlines > 0 && realNewlines >= escapedNewlines) {
|
|
1653
|
+
return false;
|
|
1654
|
+
}
|
|
1655
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
1656
|
+
const likelySourceOrMarkup = textFileExtensions.has(extension);
|
|
1657
|
+
if (!likelySourceOrMarkup) {
|
|
1658
|
+
return false;
|
|
1659
|
+
}
|
|
1660
|
+
const hasEscapedQuotes = content.includes('\\"') || content.includes("\\'");
|
|
1661
|
+
const hasSourceMarkers = /(?:<!doctype|<html|<\/\w+>|function\s|const\s|let\s|class\s|import\s|export\s|{\s*\\n|;\s*\\n|#\s|\/\*)/i.test(content);
|
|
1662
|
+
return hasEscapedQuotes || escapedNewlines >= 2 || hasSourceMarkers;
|
|
1663
|
+
}
|
|
1664
|
+
function decodeCommonJsonStringEscapes(content) {
|
|
1665
|
+
return content
|
|
1666
|
+
.replace(/\\r\\n/g, "\n")
|
|
1667
|
+
.replace(/\\n/g, "\n")
|
|
1668
|
+
.replace(/\\r/g, "\n")
|
|
1669
|
+
.replace(/\\t/g, "\t")
|
|
1670
|
+
.replace(/\\"/g, "\"")
|
|
1671
|
+
.replace(/\\'/g, "'");
|
|
1672
|
+
}
|
|
1673
|
+
function textContentMetadata(content) {
|
|
1674
|
+
const realNewlines = countOccurrences(content, "\n");
|
|
1675
|
+
return {
|
|
1676
|
+
lineCount: content.length === 0 ? 0 : realNewlines + 1,
|
|
1677
|
+
realNewlines,
|
|
1678
|
+
literalBackslashN: countOccurrences(content, "\\n"),
|
|
1679
|
+
literalEscapedQuotes: countOccurrences(content, '\\"')
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1059
1682
|
function readNumber(value, fallback) {
|
|
1060
1683
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1061
1684
|
return Math.trunc(value);
|
|
@@ -1081,7 +1704,7 @@ function isPlaceholderPath(value) {
|
|
|
1081
1704
|
const normalizedValue = value.trim().toLowerCase().replaceAll("\\", "/");
|
|
1082
1705
|
return ["relative/path", "path/to/file", "file/path", "<path>", "<file>", "filename"].includes(normalizedValue);
|
|
1083
1706
|
}
|
|
1084
|
-
function isSensitivePath(value) {
|
|
1707
|
+
export function isSensitivePath(value) {
|
|
1085
1708
|
const normalizedPath = value.trim().replaceAll("\\", "/");
|
|
1086
1709
|
return normalizedPath
|
|
1087
1710
|
.split("/")
|
|
@@ -1111,24 +1734,122 @@ function isLikelyTextFile(filePath) {
|
|
|
1111
1734
|
return textFileExtensions.has(path.extname(filePath).toLowerCase());
|
|
1112
1735
|
}
|
|
1113
1736
|
function isImageFile(filePath) {
|
|
1114
|
-
return [".png", ".jpg", ".jpeg", ".webp", ".gif"].includes(path.extname(filePath).toLowerCase());
|
|
1737
|
+
return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".heic", ".heif"].includes(path.extname(filePath).toLowerCase());
|
|
1738
|
+
}
|
|
1739
|
+
function normalizeDocumentInspectionMode(mode) {
|
|
1740
|
+
const normalizedMode = mode.trim().toLowerCase();
|
|
1741
|
+
return normalizedMode === "local" || normalizedMode === "ocr" ? normalizedMode : "auto";
|
|
1115
1742
|
}
|
|
1116
|
-
|
|
1743
|
+
function mergeFallbackDocumentResult(providerResult, fallbackResult) {
|
|
1744
|
+
if (fallbackResult.ok) {
|
|
1745
|
+
return {
|
|
1746
|
+
...fallbackResult,
|
|
1747
|
+
content: [
|
|
1748
|
+
"provider_analysis_error:",
|
|
1749
|
+
providerResult.content,
|
|
1750
|
+
"",
|
|
1751
|
+
"local_fallback:",
|
|
1752
|
+
fallbackResult.content
|
|
1753
|
+
].join("\n")
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
return providerResult;
|
|
1757
|
+
}
|
|
1758
|
+
function hasUsefulExtractedText(result) {
|
|
1759
|
+
if (!result.ok) {
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
const normalizedContent = result.content.trim().toLowerCase();
|
|
1763
|
+
return Boolean(normalizedContent) && !normalizedContent.startsWith("no extractable ");
|
|
1764
|
+
}
|
|
1765
|
+
async function inspectImageFile(filePath, documentAnalyzer, signal, mode = "auto", providerTimeoutMs = 90_000) {
|
|
1117
1766
|
const buffer = await readFile(filePath);
|
|
1118
1767
|
const dimensions = readImageDimensions(buffer, path.extname(filePath).toLowerCase());
|
|
1768
|
+
const metadata = [
|
|
1769
|
+
`image: ${path.basename(filePath)}`,
|
|
1770
|
+
`type: ${path.extname(filePath).toLowerCase().replace(".", "") || "unknown"}`,
|
|
1771
|
+
`size: ${buffer.length} bytes`,
|
|
1772
|
+
dimensions ? `dimensions: ${dimensions.width}x${dimensions.height}` : "dimensions: unknown"
|
|
1773
|
+
];
|
|
1774
|
+
if (documentAnalyzer) {
|
|
1775
|
+
try {
|
|
1776
|
+
const analysis = await runDocumentAnalyzer(documentAnalyzer, {
|
|
1777
|
+
path: filePath,
|
|
1778
|
+
prompt: "Analyze this image for PatchPilot. Extract all visible text exactly when possible, then describe the important visual elements, layout, UI state, and any errors or warnings.",
|
|
1779
|
+
signal
|
|
1780
|
+
}, providerTimeoutMs);
|
|
1781
|
+
return {
|
|
1782
|
+
ok: true,
|
|
1783
|
+
summary: `analyzed image ${path.basename(filePath)} with provider file input`,
|
|
1784
|
+
content: [...metadata, "", "provider_analysis:", clip(analysis, 20_000)].join("\n"),
|
|
1785
|
+
tool: "inspect_document",
|
|
1786
|
+
category: toolSpecs.inspect_document.category
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
catch (error) {
|
|
1790
|
+
metadata.push("", `provider_analysis_error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
if (mode === "ocr" || mode === "local") {
|
|
1794
|
+
const ocrText = await extractImageTextWithTesseract(filePath, signal);
|
|
1795
|
+
if (ocrText) {
|
|
1796
|
+
metadata.push("", "ocr_text:", clip(ocrText, 20_000));
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
if (documentAnalyzer && mode === "auto") {
|
|
1800
|
+
metadata.push("", "analysis_status: metadata_only");
|
|
1801
|
+
return {
|
|
1802
|
+
ok: false,
|
|
1803
|
+
summary: `image analysis failed for ${path.basename(filePath)}; only metadata was available`,
|
|
1804
|
+
content: metadata.join("\n"),
|
|
1805
|
+
tool: "inspect_document",
|
|
1806
|
+
category: toolSpecs.inspect_document.category,
|
|
1807
|
+
metadata: {
|
|
1808
|
+
analysisStatus: "metadata_only"
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1119
1812
|
return {
|
|
1120
1813
|
ok: true,
|
|
1121
1814
|
summary: `inspected image ${path.basename(filePath)}`,
|
|
1122
|
-
content:
|
|
1123
|
-
`image: ${path.basename(filePath)}`,
|
|
1124
|
-
`type: ${path.extname(filePath).toLowerCase().replace(".", "") || "unknown"}`,
|
|
1125
|
-
`size: ${buffer.length} bytes`,
|
|
1126
|
-
dimensions ? `dimensions: ${dimensions.width}x${dimensions.height}` : "dimensions: unknown"
|
|
1127
|
-
].join("\n"),
|
|
1815
|
+
content: metadata.join("\n"),
|
|
1128
1816
|
tool: "inspect_document",
|
|
1129
1817
|
category: toolSpecs.inspect_document.category
|
|
1130
1818
|
};
|
|
1131
1819
|
}
|
|
1820
|
+
async function runDocumentAnalyzer(documentAnalyzer, request, timeoutMs = 180_000) {
|
|
1821
|
+
const controller = new AbortController();
|
|
1822
|
+
const abort = () => controller.abort();
|
|
1823
|
+
let timeout;
|
|
1824
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1825
|
+
timeout = setTimeout(() => {
|
|
1826
|
+
controller.abort();
|
|
1827
|
+
reject(new Error(`provider file analysis timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
1828
|
+
}, timeoutMs);
|
|
1829
|
+
});
|
|
1830
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
1831
|
+
try {
|
|
1832
|
+
return await Promise.race([
|
|
1833
|
+
documentAnalyzer({
|
|
1834
|
+
...request,
|
|
1835
|
+
signal: controller.signal
|
|
1836
|
+
}),
|
|
1837
|
+
timeoutPromise
|
|
1838
|
+
]);
|
|
1839
|
+
}
|
|
1840
|
+
catch (error) {
|
|
1841
|
+
if (controller.signal.aborted && !request.signal?.aborted) {
|
|
1842
|
+
throw new Error(`provider file analysis timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
1843
|
+
}
|
|
1844
|
+
throw error;
|
|
1845
|
+
}
|
|
1846
|
+
finally {
|
|
1847
|
+
if (timeout) {
|
|
1848
|
+
clearTimeout(timeout);
|
|
1849
|
+
}
|
|
1850
|
+
request.signal?.removeEventListener("abort", abort);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1132
1853
|
function readImageDimensions(buffer, extension) {
|
|
1133
1854
|
if (extension === ".png" && buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
1134
1855
|
return {
|
|
@@ -1182,6 +1903,38 @@ async function extractPdfText(filePath, timeoutMs, signal) {
|
|
|
1182
1903
|
return denied(`PDF text extraction needs pdftotext on PATH or a text-based PDF. ${error instanceof Error ? error.message : String(error)}`);
|
|
1183
1904
|
}
|
|
1184
1905
|
}
|
|
1906
|
+
async function extractImageTextWithTesseract(filePath, signal) {
|
|
1907
|
+
let cleanupDir = "";
|
|
1908
|
+
let inputPath = filePath;
|
|
1909
|
+
try {
|
|
1910
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
1911
|
+
if (extension === ".heic" || extension === ".heif") {
|
|
1912
|
+
cleanupDir = await mkdtemp(path.join(tmpdir(), "patchpilot-ocr-"));
|
|
1913
|
+
inputPath = path.join(cleanupDir, "image.png");
|
|
1914
|
+
await execFileAsync("sips", ["-s", "format", "png", filePath, "--out", inputPath], {
|
|
1915
|
+
timeout: 60_000,
|
|
1916
|
+
signal,
|
|
1917
|
+
windowsHide: true
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
const { stdout } = await execFileAsync("tesseract", [inputPath, "stdout", "-l", "eng+deu"], {
|
|
1921
|
+
timeout: 90_000,
|
|
1922
|
+
maxBuffer: 2_000_000,
|
|
1923
|
+
signal,
|
|
1924
|
+
windowsHide: true
|
|
1925
|
+
});
|
|
1926
|
+
const text = stdout.trim();
|
|
1927
|
+
return text || null;
|
|
1928
|
+
}
|
|
1929
|
+
catch {
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
finally {
|
|
1933
|
+
if (cleanupDir) {
|
|
1934
|
+
await rm(cleanupDir, { recursive: true, force: true });
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1185
1938
|
async function extractDocxText(filePath) {
|
|
1186
1939
|
try {
|
|
1187
1940
|
const archive = await readFile(filePath);
|
|
@@ -1197,6 +1950,147 @@ async function extractDocxText(filePath) {
|
|
|
1197
1950
|
return denied(`DOCX text extraction needs unzip on PATH and a valid .docx file. ${error instanceof Error ? error.message : String(error)}`);
|
|
1198
1951
|
}
|
|
1199
1952
|
}
|
|
1953
|
+
async function extractLegacyDocText(filePath, timeoutMs, signal) {
|
|
1954
|
+
try {
|
|
1955
|
+
const { stdout } = await execFileAsync("textutil", ["-convert", "txt", "-stdout", filePath], {
|
|
1956
|
+
timeout: timeoutMs,
|
|
1957
|
+
signal,
|
|
1958
|
+
maxBuffer: 2_000_000
|
|
1959
|
+
});
|
|
1960
|
+
return {
|
|
1961
|
+
ok: true,
|
|
1962
|
+
summary: `extracted text from ${path.basename(filePath)}`,
|
|
1963
|
+
content: clip(stdout || "No extractable DOC text found.", 20_000)
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
catch (error) {
|
|
1967
|
+
return denied(`DOC text extraction needs macOS textutil and a valid .doc file. ${error instanceof Error ? error.message : String(error)}`);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
function createSimplePdf(content, title) {
|
|
1971
|
+
const lines = wrapPdfText(`${title ? `${title}\n\n` : ""}${content}`, 92).slice(0, 44);
|
|
1972
|
+
const escapedLines = lines.map((line) => `(${escapePdfString(line)}) Tj`).join("\n0 -14 Td\n");
|
|
1973
|
+
const stream = `BT\n/F1 11 Tf\n50 780 Td\n14 TL\n${escapedLines}\nET`;
|
|
1974
|
+
const objects = [
|
|
1975
|
+
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
1976
|
+
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
1977
|
+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
|
1978
|
+
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
1979
|
+
`<< /Length ${Buffer.byteLength(stream, "utf8")} >>\nstream\n${stream}\nendstream`
|
|
1980
|
+
];
|
|
1981
|
+
const chunks = ["%PDF-1.4\n"];
|
|
1982
|
+
const offsets = [0];
|
|
1983
|
+
for (const [index, object] of objects.entries()) {
|
|
1984
|
+
offsets.push(Buffer.byteLength(chunks.join(""), "utf8"));
|
|
1985
|
+
chunks.push(`${index + 1} 0 obj\n${object}\nendobj\n`);
|
|
1986
|
+
}
|
|
1987
|
+
const xrefOffset = Buffer.byteLength(chunks.join(""), "utf8");
|
|
1988
|
+
chunks.push(`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`);
|
|
1989
|
+
for (const offset of offsets.slice(1)) {
|
|
1990
|
+
chunks.push(`${offset.toString().padStart(10, "0")} 00000 n \n`);
|
|
1991
|
+
}
|
|
1992
|
+
chunks.push(`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`);
|
|
1993
|
+
return Buffer.from(chunks.join(""), "utf8");
|
|
1994
|
+
}
|
|
1995
|
+
function wrapPdfText(value, width) {
|
|
1996
|
+
const lines = [];
|
|
1997
|
+
for (const rawLine of value.replace(/\r\n?/g, "\n").split("\n")) {
|
|
1998
|
+
let line = rawLine.trimEnd();
|
|
1999
|
+
while (line.length > width) {
|
|
2000
|
+
const breakAt = Math.max(line.lastIndexOf(" ", width), 1);
|
|
2001
|
+
lines.push(line.slice(0, breakAt).trimEnd());
|
|
2002
|
+
line = line.slice(breakAt).trimStart();
|
|
2003
|
+
}
|
|
2004
|
+
lines.push(line);
|
|
2005
|
+
}
|
|
2006
|
+
return lines;
|
|
2007
|
+
}
|
|
2008
|
+
function escapePdfString(value) {
|
|
2009
|
+
return value.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)");
|
|
2010
|
+
}
|
|
2011
|
+
function createSimpleDocx(content, title) {
|
|
2012
|
+
const paragraphs = `${title ? `${title}\n\n` : ""}${content}`
|
|
2013
|
+
.replace(/\r\n?/g, "\n")
|
|
2014
|
+
.split(/\n{2,}/)
|
|
2015
|
+
.map((paragraph) => paragraph.trim())
|
|
2016
|
+
.filter(Boolean);
|
|
2017
|
+
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
2018
|
+
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paragraphs
|
|
2019
|
+
.map((paragraph) => `<w:p><w:r><w:t xml:space="preserve">${escapeXml(paragraph)}</w:t></w:r></w:p>`)
|
|
2020
|
+
.join("")}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`;
|
|
2021
|
+
return createZip([
|
|
2022
|
+
{
|
|
2023
|
+
name: "[Content_Types].xml",
|
|
2024
|
+
content: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`
|
|
2025
|
+
},
|
|
2026
|
+
{
|
|
2027
|
+
name: "_rels/.rels",
|
|
2028
|
+
content: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
|
|
2029
|
+
},
|
|
2030
|
+
{
|
|
2031
|
+
name: "word/document.xml",
|
|
2032
|
+
content: documentXml
|
|
2033
|
+
}
|
|
2034
|
+
]);
|
|
2035
|
+
}
|
|
2036
|
+
function createZip(entries) {
|
|
2037
|
+
const localRecords = [];
|
|
2038
|
+
const centralRecords = [];
|
|
2039
|
+
let offset = 0;
|
|
2040
|
+
for (const entry of entries) {
|
|
2041
|
+
const name = Buffer.from(entry.name);
|
|
2042
|
+
const content = Buffer.from(entry.content, "utf8");
|
|
2043
|
+
const compressed = deflateRawSync(content);
|
|
2044
|
+
const crc = crc32(content);
|
|
2045
|
+
const localHeader = Buffer.alloc(30);
|
|
2046
|
+
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
2047
|
+
localHeader.writeUInt16LE(20, 4);
|
|
2048
|
+
localHeader.writeUInt16LE(8, 8);
|
|
2049
|
+
localHeader.writeUInt32LE(crc, 14);
|
|
2050
|
+
localHeader.writeUInt32LE(compressed.length, 18);
|
|
2051
|
+
localHeader.writeUInt32LE(content.length, 22);
|
|
2052
|
+
localHeader.writeUInt16LE(name.length, 26);
|
|
2053
|
+
const localRecord = Buffer.concat([localHeader, name, compressed]);
|
|
2054
|
+
localRecords.push(localRecord);
|
|
2055
|
+
const centralHeader = Buffer.alloc(46);
|
|
2056
|
+
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
2057
|
+
centralHeader.writeUInt16LE(20, 4);
|
|
2058
|
+
centralHeader.writeUInt16LE(20, 6);
|
|
2059
|
+
centralHeader.writeUInt16LE(8, 10);
|
|
2060
|
+
centralHeader.writeUInt32LE(crc, 16);
|
|
2061
|
+
centralHeader.writeUInt32LE(compressed.length, 20);
|
|
2062
|
+
centralHeader.writeUInt32LE(content.length, 24);
|
|
2063
|
+
centralHeader.writeUInt16LE(name.length, 28);
|
|
2064
|
+
centralHeader.writeUInt32LE(offset, 42);
|
|
2065
|
+
centralRecords.push(Buffer.concat([centralHeader, name]));
|
|
2066
|
+
offset += localRecord.length;
|
|
2067
|
+
}
|
|
2068
|
+
const centralDirectory = Buffer.concat(centralRecords);
|
|
2069
|
+
const endRecord = Buffer.alloc(22);
|
|
2070
|
+
endRecord.writeUInt32LE(0x06054b50, 0);
|
|
2071
|
+
endRecord.writeUInt16LE(entries.length, 8);
|
|
2072
|
+
endRecord.writeUInt16LE(entries.length, 10);
|
|
2073
|
+
endRecord.writeUInt32LE(centralDirectory.length, 12);
|
|
2074
|
+
endRecord.writeUInt32LE(offset, 16);
|
|
2075
|
+
return Buffer.concat([...localRecords, centralDirectory, endRecord]);
|
|
2076
|
+
}
|
|
2077
|
+
function crc32(buffer) {
|
|
2078
|
+
let crc = 0xffffffff;
|
|
2079
|
+
for (const byte of buffer) {
|
|
2080
|
+
crc ^= byte;
|
|
2081
|
+
for (let index = 0; index < 8; index += 1) {
|
|
2082
|
+
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
2086
|
+
}
|
|
2087
|
+
function escapeXml(value) {
|
|
2088
|
+
return value
|
|
2089
|
+
.replaceAll("&", "&")
|
|
2090
|
+
.replaceAll("<", "<")
|
|
2091
|
+
.replaceAll(">", ">")
|
|
2092
|
+
.replaceAll('"', """);
|
|
2093
|
+
}
|
|
1200
2094
|
function readZipEntryText(archive, entryName) {
|
|
1201
2095
|
const endOfCentralDirectoryOffset = findEndOfCentralDirectory(archive);
|
|
1202
2096
|
if (endOfCentralDirectoryOffset < 0) {
|
|
@@ -1269,36 +2163,124 @@ function clip(content, maxLength) {
|
|
|
1269
2163
|
return `${content.slice(0, maxLength)}\n...[clipped ${content.length - maxLength} chars]`;
|
|
1270
2164
|
}
|
|
1271
2165
|
function previewPatch(patchContent) {
|
|
1272
|
-
const changedFiles = patchContent
|
|
1273
|
-
.split(/\r?\n/)
|
|
1274
|
-
.filter((line) => line.startsWith("+++ ") || line.startsWith("--- "))
|
|
1275
|
-
.map((line) => line.slice(4).replace(/^a\//, "").replace(/^b\//, ""))
|
|
1276
|
-
.filter((file) => file !== "/dev/null");
|
|
2166
|
+
const changedFiles = extractPatchTargetPaths(patchContent);
|
|
1277
2167
|
const uniqueFiles = [...new Set(changedFiles)].slice(0, 6);
|
|
1278
2168
|
const fileSummary = uniqueFiles.length > 0 ? uniqueFiles.join(", ") : "unknown files";
|
|
1279
2169
|
const added = patchContent.split(/\r?\n/).filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
|
|
1280
2170
|
const removed = patchContent.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
|
|
1281
2171
|
return `Apply patch to ${fileSummary} (+${added}/-${removed}).`;
|
|
1282
2172
|
}
|
|
1283
|
-
function
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
2173
|
+
function extractPatchTargetPaths(patchContent) {
|
|
2174
|
+
const paths = [];
|
|
2175
|
+
for (const line of patchContent.split(/\r?\n/)) {
|
|
2176
|
+
if (!line.startsWith("+++ ") && !line.startsWith("--- ")) {
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2179
|
+
const rawPath = normalizePatchHeaderPath(line.slice(4));
|
|
2180
|
+
const normalizedPath = rawPath.replace(/^a\//, "").replace(/^b\//, "");
|
|
2181
|
+
if (normalizedPath && normalizedPath !== "/dev/null") {
|
|
2182
|
+
paths.push(normalizedPath);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return [...new Set(paths)];
|
|
2186
|
+
}
|
|
2187
|
+
function patchCreatesSymlink(patchContent) {
|
|
2188
|
+
return /^new file mode 120000$/m.test(patchContent) || /^new mode 120000$/m.test(patchContent);
|
|
2189
|
+
}
|
|
2190
|
+
function normalizePatchHeaderPath(value) {
|
|
2191
|
+
const trimmedValue = value.trim();
|
|
2192
|
+
if (!trimmedValue || trimmedValue === "/dev/null") {
|
|
2193
|
+
return trimmedValue;
|
|
2194
|
+
}
|
|
2195
|
+
if (trimmedValue.startsWith("\"")) {
|
|
2196
|
+
try {
|
|
2197
|
+
return JSON.parse(trimmedValue);
|
|
2198
|
+
}
|
|
2199
|
+
catch {
|
|
2200
|
+
return trimmedValue.slice(1).split("\"")[0] ?? trimmedValue;
|
|
2201
|
+
}
|
|
1287
2202
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
2203
|
+
return trimmedValue;
|
|
2204
|
+
}
|
|
2205
|
+
function validateShellCommand(command, workspaceRoot, options) {
|
|
2206
|
+
const trimmedCommand = command.trim();
|
|
2207
|
+
const syntax = analyzeShellSyntax(trimmedCommand);
|
|
2208
|
+
if (!options.allowMetacharacters) {
|
|
2209
|
+
if (syntax.hasShellChains || syntax.highRiskReasons.length > 0) {
|
|
2210
|
+
return {
|
|
2211
|
+
error: "shell metacharacters beyond pipes require /experimental shell-metacharacters; redirects, expansion, background jobs, OR chains, and multiline commands stay approval-gated."
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
1290
2214
|
}
|
|
1291
|
-
const tokens =
|
|
2215
|
+
const tokens = syntax.tokens;
|
|
1292
2216
|
if (tokens.length === 0) {
|
|
1293
|
-
return "command is empty.";
|
|
2217
|
+
return { error: "command is empty." };
|
|
1294
2218
|
}
|
|
1295
|
-
for (const segment of
|
|
2219
|
+
for (const segment of splitShellCommandSegments(tokens)) {
|
|
1296
2220
|
const segmentError = validateShellSegment(segment);
|
|
1297
2221
|
if (segmentError) {
|
|
1298
|
-
return segmentError;
|
|
2222
|
+
return { error: segmentError };
|
|
1299
2223
|
}
|
|
1300
2224
|
}
|
|
1301
|
-
for (const token of tokens.filter((value) => value
|
|
2225
|
+
for (const token of tokens.filter((value) => !shellOperatorTokens.has(value))) {
|
|
2226
|
+
const normalizedToken = stripQuotes(token);
|
|
2227
|
+
if (isSensitivePath(normalizedToken)) {
|
|
2228
|
+
return { error: "sensitive path arguments are blocked." };
|
|
2229
|
+
}
|
|
2230
|
+
if (/(^|[\\/])\.\.([\\/]|$)/.test(normalizedToken)) {
|
|
2231
|
+
return { error: "parent directory traversal is blocked." };
|
|
2232
|
+
}
|
|
2233
|
+
const absolutePath = toAbsoluteShellPath(normalizedToken);
|
|
2234
|
+
if (absolutePath) {
|
|
2235
|
+
const relativePath = path.relative(workspaceRoot, absolutePath);
|
|
2236
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
2237
|
+
return { error: "absolute path arguments outside the workspace are blocked. Use inspect_document with /experimental file-analysis for external files." };
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
return {
|
|
2242
|
+
error: null,
|
|
2243
|
+
requiresApprovalReason: syntax.highRiskReasons[0]
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
function collectPackageScriptCommands(scripts, scriptName) {
|
|
2247
|
+
return [`pre${scriptName}`, scriptName, `post${scriptName}`]
|
|
2248
|
+
.filter((name) => typeof scripts[name] === "string")
|
|
2249
|
+
.map((name) => ({
|
|
2250
|
+
name,
|
|
2251
|
+
command: scripts[name] ?? ""
|
|
2252
|
+
}));
|
|
2253
|
+
}
|
|
2254
|
+
function validatePackageScriptCommands(commands, workspaceRoot) {
|
|
2255
|
+
for (const entry of commands) {
|
|
2256
|
+
const error = validatePackageScriptCommand(entry.command, workspaceRoot);
|
|
2257
|
+
if (error) {
|
|
2258
|
+
return `${entry.name}: ${error}`;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
return null;
|
|
2262
|
+
}
|
|
2263
|
+
function validatePackageScriptCommand(command, workspaceRoot) {
|
|
2264
|
+
const trimmedCommand = command.trim();
|
|
2265
|
+
if (!trimmedCommand) {
|
|
2266
|
+
return "package script is empty.";
|
|
2267
|
+
}
|
|
2268
|
+
if (/[;<>`$\n\r]/.test(trimmedCommand)) {
|
|
2269
|
+
return "dangerous shell metacharacters are blocked in package scripts before approval.";
|
|
2270
|
+
}
|
|
2271
|
+
if (/(^|\s)&($|\s)/.test(trimmedCommand)) {
|
|
2272
|
+
return "background shell execution is blocked in package scripts.";
|
|
2273
|
+
}
|
|
2274
|
+
const tokens = tokenizeShellCommand(trimmedCommand);
|
|
2275
|
+
for (const commandTokens of splitPackageCommandTokens(tokens)) {
|
|
2276
|
+
for (const segment of splitPipeline(commandTokens)) {
|
|
2277
|
+
const segmentError = validatePackageScriptSegment(segment);
|
|
2278
|
+
if (segmentError) {
|
|
2279
|
+
return segmentError;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
for (const token of tokens.filter((value) => value !== "|" && value !== "&&" && value !== "||")) {
|
|
1302
2284
|
const normalizedToken = stripQuotes(token);
|
|
1303
2285
|
if (isSensitivePath(normalizedToken)) {
|
|
1304
2286
|
return "sensitive path arguments are blocked.";
|
|
@@ -1310,12 +2292,153 @@ function validateShellCommand(command, workspaceRoot) {
|
|
|
1310
2292
|
if (absolutePath) {
|
|
1311
2293
|
const relativePath = path.relative(workspaceRoot, absolutePath);
|
|
1312
2294
|
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
1313
|
-
return "absolute path arguments outside the workspace are blocked.
|
|
2295
|
+
return "absolute path arguments outside the workspace are blocked.";
|
|
1314
2296
|
}
|
|
1315
2297
|
}
|
|
1316
2298
|
}
|
|
1317
2299
|
return null;
|
|
1318
2300
|
}
|
|
2301
|
+
function splitPackageCommandTokens(tokens) {
|
|
2302
|
+
const commands = [[]];
|
|
2303
|
+
for (const token of tokens) {
|
|
2304
|
+
if (token === "&&" || token === "||") {
|
|
2305
|
+
commands.push([]);
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
commands.at(-1)?.push(token);
|
|
2309
|
+
}
|
|
2310
|
+
return commands;
|
|
2311
|
+
}
|
|
2312
|
+
const shellOperatorTokens = new Set(["|", "&&", "||", ";", "&", "<", "<<", ">", ">>"]);
|
|
2313
|
+
function analyzeShellSyntax(command) {
|
|
2314
|
+
const tokens = [];
|
|
2315
|
+
const highRiskReasons = new Set();
|
|
2316
|
+
let current = "";
|
|
2317
|
+
let quote = null;
|
|
2318
|
+
let escaping = false;
|
|
2319
|
+
let hasShellChains = false;
|
|
2320
|
+
const pushCurrent = () => {
|
|
2321
|
+
if (current) {
|
|
2322
|
+
tokens.push(current);
|
|
2323
|
+
current = "";
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
const pushOperator = (operator) => {
|
|
2327
|
+
pushCurrent();
|
|
2328
|
+
tokens.push(operator);
|
|
2329
|
+
};
|
|
2330
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
2331
|
+
const char = command[index] ?? "";
|
|
2332
|
+
const next = command[index + 1] ?? "";
|
|
2333
|
+
if (escaping) {
|
|
2334
|
+
current += char;
|
|
2335
|
+
escaping = false;
|
|
2336
|
+
continue;
|
|
2337
|
+
}
|
|
2338
|
+
if (char === "\\") {
|
|
2339
|
+
current += char;
|
|
2340
|
+
escaping = true;
|
|
2341
|
+
continue;
|
|
2342
|
+
}
|
|
2343
|
+
if (quote) {
|
|
2344
|
+
current += char;
|
|
2345
|
+
if (quote === "\"" && (char === "$" || char === "`")) {
|
|
2346
|
+
highRiskReasons.add("shell expansion");
|
|
2347
|
+
}
|
|
2348
|
+
if (char === quote) {
|
|
2349
|
+
quote = null;
|
|
2350
|
+
}
|
|
2351
|
+
continue;
|
|
2352
|
+
}
|
|
2353
|
+
if (char === "'" || char === "\"") {
|
|
2354
|
+
current += char;
|
|
2355
|
+
quote = char;
|
|
2356
|
+
continue;
|
|
2357
|
+
}
|
|
2358
|
+
if (char === "\n" || char === "\r") {
|
|
2359
|
+
pushCurrent();
|
|
2360
|
+
highRiskReasons.add("multiline command");
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
if (/\s/.test(char)) {
|
|
2364
|
+
pushCurrent();
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2367
|
+
if (char === "|") {
|
|
2368
|
+
if (next === "|") {
|
|
2369
|
+
pushOperator("||");
|
|
2370
|
+
highRiskReasons.add("OR chain");
|
|
2371
|
+
index += 1;
|
|
2372
|
+
}
|
|
2373
|
+
else {
|
|
2374
|
+
pushOperator("|");
|
|
2375
|
+
}
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
if (char === "&") {
|
|
2379
|
+
if (next === "&") {
|
|
2380
|
+
pushOperator("&&");
|
|
2381
|
+
hasShellChains = true;
|
|
2382
|
+
index += 1;
|
|
2383
|
+
}
|
|
2384
|
+
else {
|
|
2385
|
+
pushOperator("&");
|
|
2386
|
+
highRiskReasons.add("background execution");
|
|
2387
|
+
}
|
|
2388
|
+
continue;
|
|
2389
|
+
}
|
|
2390
|
+
if (char === ";") {
|
|
2391
|
+
pushOperator(";");
|
|
2392
|
+
hasShellChains = true;
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
if (char === "<" || char === ">") {
|
|
2396
|
+
const operator = next === char ? `${char}${next}` : char;
|
|
2397
|
+
pushOperator(operator);
|
|
2398
|
+
highRiskReasons.add("redirection");
|
|
2399
|
+
if (next === char) {
|
|
2400
|
+
index += 1;
|
|
2401
|
+
}
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
if (char === "$" || char === "`") {
|
|
2405
|
+
highRiskReasons.add("shell expansion");
|
|
2406
|
+
}
|
|
2407
|
+
if (char === "*" || char === "?" || char === "[") {
|
|
2408
|
+
highRiskReasons.add("glob expansion");
|
|
2409
|
+
}
|
|
2410
|
+
current += char;
|
|
2411
|
+
}
|
|
2412
|
+
pushCurrent();
|
|
2413
|
+
return {
|
|
2414
|
+
tokens,
|
|
2415
|
+
hasShellChains,
|
|
2416
|
+
highRiskReasons: [...highRiskReasons]
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
function tokenizeShellCommand(command) {
|
|
2420
|
+
return analyzeShellSyntax(command).tokens;
|
|
2421
|
+
}
|
|
2422
|
+
function validatePackageScriptSegment(tokens) {
|
|
2423
|
+
if (tokens.length === 0) {
|
|
2424
|
+
return "empty shell pipeline segment.";
|
|
2425
|
+
}
|
|
2426
|
+
const executable = stripQuotes(tokens[0] ?? "").toLowerCase();
|
|
2427
|
+
const subcommand = findCommandSubcommand(executable, tokens.slice(1));
|
|
2428
|
+
if (["bash", "sh", "zsh", "fish", "pwsh", "powershell", "powershell.exe"].includes(executable)) {
|
|
2429
|
+
return `executable "${executable}" is blocked in package scripts.`;
|
|
2430
|
+
}
|
|
2431
|
+
if (["rm", "rmdir", "mv", "cp"].includes(executable) && tokens.some((token) => /^-.*[fRr]/.test(stripQuotes(token)))) {
|
|
2432
|
+
return `destructive ${executable} flags are blocked.`;
|
|
2433
|
+
}
|
|
2434
|
+
if (executable === "git" && subcommand && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
|
|
2435
|
+
return `git ${subcommand} is blocked in package scripts.`;
|
|
2436
|
+
}
|
|
2437
|
+
if (executable === "npm" && subcommand && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
|
|
2438
|
+
return `npm ${subcommand} is blocked in package scripts.`;
|
|
2439
|
+
}
|
|
2440
|
+
return null;
|
|
2441
|
+
}
|
|
1319
2442
|
function validateShellSegment(tokens) {
|
|
1320
2443
|
if (tokens.length === 0) {
|
|
1321
2444
|
return "empty shell pipeline segment.";
|
|
@@ -1336,6 +2459,17 @@ function validateShellSegment(tokens) {
|
|
|
1336
2459
|
}
|
|
1337
2460
|
return null;
|
|
1338
2461
|
}
|
|
2462
|
+
function splitShellCommandSegments(tokens) {
|
|
2463
|
+
const segments = [[]];
|
|
2464
|
+
for (const token of tokens) {
|
|
2465
|
+
if (["|", "&&", "||", ";", "&"].includes(token)) {
|
|
2466
|
+
segments.push([]);
|
|
2467
|
+
continue;
|
|
2468
|
+
}
|
|
2469
|
+
segments.at(-1)?.push(token);
|
|
2470
|
+
}
|
|
2471
|
+
return segments;
|
|
2472
|
+
}
|
|
1339
2473
|
function splitPipeline(tokens) {
|
|
1340
2474
|
const segments = [[]];
|
|
1341
2475
|
for (const token of tokens) {
|
|
@@ -1376,14 +2510,59 @@ function toAbsoluteShellPath(value) {
|
|
|
1376
2510
|
return path.resolve(value);
|
|
1377
2511
|
}
|
|
1378
2512
|
if (value === "~" || value.startsWith("~/")) {
|
|
1379
|
-
return path.resolve(
|
|
2513
|
+
return path.resolve(homedir(), value === "~" ? "." : value.slice(2));
|
|
1380
2514
|
}
|
|
1381
2515
|
return null;
|
|
1382
2516
|
}
|
|
1383
|
-
function
|
|
1384
|
-
const
|
|
2517
|
+
function previewPackageScriptSequence(name, commands, workspaceRoot) {
|
|
2518
|
+
const commandSummary = commands.map((entry) => `${entry.name}: ${entry.command}`).join(" && ");
|
|
2519
|
+
const risk = validatePackageScriptCommands(commands, workspaceRoot);
|
|
1385
2520
|
const prefix = risk ? `Risky package script (${risk})` : "Run package script";
|
|
1386
|
-
return `${prefix}: npm run ${name} -> ${clip(
|
|
2521
|
+
return `${prefix}: npm run ${name} -> ${clip(commandSummary, 220)}`;
|
|
2522
|
+
}
|
|
2523
|
+
function approvalScopeKey(tool, permission, args) {
|
|
2524
|
+
const target = approvalScopeTarget(tool, args);
|
|
2525
|
+
return `${permission}:${tool}:${target}`;
|
|
2526
|
+
}
|
|
2527
|
+
function approvalScopeTarget(tool, args) {
|
|
2528
|
+
switch (tool) {
|
|
2529
|
+
case "write_file":
|
|
2530
|
+
case "edit_file":
|
|
2531
|
+
case "create_pdf":
|
|
2532
|
+
case "create_docx":
|
|
2533
|
+
return `path:${normalizeScopeValue(readString(args.path, ""))}`;
|
|
2534
|
+
case "apply_patch":
|
|
2535
|
+
return `patch:${readString(args.patchHash, "") || stableHash(readString(args.patch, ""))}`;
|
|
2536
|
+
case "run_script":
|
|
2537
|
+
case "run_tests": {
|
|
2538
|
+
const script = normalizeScopeValue(readString(args.script, tool === "run_tests" ? "test" : ""));
|
|
2539
|
+
const command = normalizeCommandForScope(readString(args.command, ""));
|
|
2540
|
+
return `script:${script}:${stableHash(command)}`;
|
|
2541
|
+
}
|
|
2542
|
+
case "run_shell":
|
|
2543
|
+
return `command:${stableHash(normalizeCommandForScope(readString(args.command, "")))}`;
|
|
2544
|
+
case "inspect_document":
|
|
2545
|
+
return `external:${normalizeScopeValue(readString(args.path, ""))}`;
|
|
2546
|
+
case "memory_remember":
|
|
2547
|
+
return `memory:${stableHash(readString(args.content, ""))}`;
|
|
2548
|
+
default:
|
|
2549
|
+
return stableHash(JSON.stringify(args));
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
function normalizeScopeValue(value) {
|
|
2553
|
+
return value.trim().replaceAll("\\", "/").replace(/\/+/g, "/").replace(/^\.\//, "") || ".";
|
|
2554
|
+
}
|
|
2555
|
+
function normalizeCommandForScope(value) {
|
|
2556
|
+
return value.trim().replace(/\s+/g, " ");
|
|
2557
|
+
}
|
|
2558
|
+
function stableHash(value) {
|
|
2559
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
2560
|
+
}
|
|
2561
|
+
function readStringRecord(value) {
|
|
2562
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2563
|
+
return {};
|
|
2564
|
+
}
|
|
2565
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
|
|
1387
2566
|
}
|
|
1388
2567
|
function stripQuotes(value) {
|
|
1389
2568
|
return value.replace(/^['"]|['"]$/g, "");
|