@jx-grxf/patchpilot 0.4.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/.env.example +17 -1
- package/README.md +113 -23
- package/SECURITY.md +7 -1
- package/dist/cli.js +103 -14
- package/dist/cli.js.map +1 -1
- package/dist/core/agent.d.ts +47 -1
- package/dist/core/agent.js +667 -76
- package/dist/core/agent.js.map +1 -1
- package/dist/core/cleanup.d.ts +3 -0
- package/dist/core/cleanup.js +29 -0
- package/dist/core/cleanup.js.map +1 -0
- 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.d.ts +4 -1
- package/dist/core/doctor.js +122 -3
- 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 +92 -0
- package/dist/core/geminiWrapper.js +1258 -0
- package/dist/core/geminiWrapper.js.map +1 -0
- 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 +81 -19
- package/dist/core/json.js.map +1 -1
- package/dist/core/memory.d.ts +16 -0
- package/dist/core/memory.js +108 -0
- package/dist/core/memory.js.map +1 -0
- package/dist/core/modelClient.js +7 -0
- package/dist/core/modelClient.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/projectInit.d.ts +6 -0
- package/dist/core/projectInit.js +44 -0
- package/dist/core/projectInit.js.map +1 -0
- package/dist/core/reasoning.js +6 -0
- package/dist/core/reasoning.js.map +1 -1
- package/dist/core/session.d.ts +1 -0
- package/dist/core/session.js +55 -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 +65 -5
- 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 +37 -0
- package/dist/core/workspace.js +1535 -84
- package/dist/core/workspace.js.map +1 -1
- package/dist/tui/App.d.ts +1 -0
- package/dist/tui/App.js +1841 -140
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/commands.js +141 -9
- 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 +33 -5
- 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 +10 -0
- package/dist/tui/components/ExperimentalPanel.js +38 -0
- package/dist/tui/components/ExperimentalPanel.js.map +1 -0
- package/dist/tui/components/Header.js +3 -3
- package/dist/tui/components/Header.js.map +1 -1
- package/dist/tui/components/OnboardingPanel.d.ts +25 -1
- package/dist/tui/components/OnboardingPanel.js +87 -25
- package/dist/tui/components/OnboardingPanel.js.map +1 -1
- package/dist/tui/components/Sidebar.js +17 -13
- package/dist/tui/components/Sidebar.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 +87 -17
- 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 +21 -7
- 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 +8 -1
- package/dist/tui/modes.js +20 -2
- 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 +110 -0
- package/docs/product-context.md +43 -0
- package/docs/releases/v0.1.1-beta.md +18 -0
- package/docs/releases/v0.2.1.md +1 -1
- package/docs/releases/v0.3.1-beta.md +4 -0
- package/docs/releases/v0.4.0.md +1 -1
- package/docs/releases/v1.0.0.md +28 -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/docs/showcase/patchpilot-banner.png +0 -0
- package/docs/showcase/patchpilot-logo.png +0 -0
- package/package.json +8 -3
package/dist/core/workspace.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
9
|
+
import { MemoryStore } from "./memory.js";
|
|
8
10
|
const execFileAsync = promisify(execFile);
|
|
9
11
|
const ignoredDirectories = new Set([
|
|
10
12
|
".git",
|
|
@@ -27,6 +29,7 @@ const textFileExtensions = new Set([
|
|
|
27
29
|
".h",
|
|
28
30
|
".hpp",
|
|
29
31
|
".html",
|
|
32
|
+
".svg",
|
|
30
33
|
".js",
|
|
31
34
|
".json",
|
|
32
35
|
".jsx",
|
|
@@ -38,6 +41,7 @@ const textFileExtensions = new Set([
|
|
|
38
41
|
".ts",
|
|
39
42
|
".tsx",
|
|
40
43
|
".txt",
|
|
44
|
+
".jsonl",
|
|
41
45
|
".java",
|
|
42
46
|
".kt",
|
|
43
47
|
".go",
|
|
@@ -73,7 +77,20 @@ const blockedPathNames = new Set([
|
|
|
73
77
|
"id_dsa",
|
|
74
78
|
"known_hosts"
|
|
75
79
|
]);
|
|
80
|
+
const blockedPathPatterns = [
|
|
81
|
+
/(^|\/)(cookies|network\/cookies|login data|web data)$/i,
|
|
82
|
+
/(^|\/)(chrome|chromium|brave-browser|brave|microsoft edge|edge|arc|firefox|safari)(\/|$)/i,
|
|
83
|
+
/(^|\/)(default|profile \d+|profiles?)\/(cookies|network\/cookies|login data|web data)$/i
|
|
84
|
+
];
|
|
76
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
|
+
},
|
|
77
94
|
list_files: {
|
|
78
95
|
name: "list_files",
|
|
79
96
|
description: "List workspace files under a directory.",
|
|
@@ -82,6 +99,14 @@ export const toolSpecs = {
|
|
|
82
99
|
permission: "none",
|
|
83
100
|
category: "read"
|
|
84
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
|
+
},
|
|
85
110
|
read_file: {
|
|
86
111
|
name: "read_file",
|
|
87
112
|
description: "Read a complete text/code file.",
|
|
@@ -122,6 +147,22 @@ export const toolSpecs = {
|
|
|
122
147
|
permission: "none",
|
|
123
148
|
category: "document"
|
|
124
149
|
},
|
|
150
|
+
memory_remember: {
|
|
151
|
+
name: "memory_remember",
|
|
152
|
+
description: "Store a durable memory for this workspace.",
|
|
153
|
+
risk: "low",
|
|
154
|
+
sideEffects: "write",
|
|
155
|
+
permission: "write",
|
|
156
|
+
category: "memory"
|
|
157
|
+
},
|
|
158
|
+
memory_search: {
|
|
159
|
+
name: "memory_search",
|
|
160
|
+
description: "Search durable workspace memories.",
|
|
161
|
+
risk: "low",
|
|
162
|
+
sideEffects: "none",
|
|
163
|
+
permission: "none",
|
|
164
|
+
category: "memory"
|
|
165
|
+
},
|
|
125
166
|
git_status: {
|
|
126
167
|
name: "git_status",
|
|
127
168
|
description: "Read the current Git branch and dirty state.",
|
|
@@ -138,6 +179,22 @@ export const toolSpecs = {
|
|
|
138
179
|
permission: "none",
|
|
139
180
|
category: "git"
|
|
140
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
|
+
},
|
|
141
198
|
list_changed_files: {
|
|
142
199
|
name: "list_changed_files",
|
|
143
200
|
description: "List changed files from Git porcelain status.",
|
|
@@ -154,6 +211,30 @@ export const toolSpecs = {
|
|
|
154
211
|
permission: "none",
|
|
155
212
|
category: "read"
|
|
156
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
|
+
},
|
|
157
238
|
write_file: {
|
|
158
239
|
name: "write_file",
|
|
159
240
|
description: "Write a full file in the workspace.",
|
|
@@ -162,6 +243,30 @@ export const toolSpecs = {
|
|
|
162
243
|
permission: "write",
|
|
163
244
|
category: "write"
|
|
164
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
|
+
},
|
|
165
270
|
apply_patch: {
|
|
166
271
|
name: "apply_patch",
|
|
167
272
|
description: "Apply a unified Git patch inside the workspace.",
|
|
@@ -203,6 +308,10 @@ export class WorkspaceTools {
|
|
|
203
308
|
rootRealPath;
|
|
204
309
|
allowWrite;
|
|
205
310
|
allowShell;
|
|
311
|
+
allowShellMetacharacters;
|
|
312
|
+
allowExternalFileAnalysis;
|
|
313
|
+
memoryEnabled;
|
|
314
|
+
documentAnalyzer;
|
|
206
315
|
timeoutMs;
|
|
207
316
|
signal;
|
|
208
317
|
approvalHandler;
|
|
@@ -212,6 +321,10 @@ export class WorkspaceTools {
|
|
|
212
321
|
this.rootRealPath = realpath(this.root).catch(() => this.root);
|
|
213
322
|
this.allowWrite = options.allowWrite;
|
|
214
323
|
this.allowShell = options.allowShell;
|
|
324
|
+
this.allowShellMetacharacters = Boolean(options.allowShellMetacharacters);
|
|
325
|
+
this.allowExternalFileAnalysis = Boolean(options.allowExternalFileAnalysis);
|
|
326
|
+
this.documentAnalyzer = options.documentAnalyzer;
|
|
327
|
+
this.memoryEnabled = Boolean(options.memoryEnabled);
|
|
215
328
|
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
216
329
|
this.signal = options.signal;
|
|
217
330
|
this.approvalHandler = options.approvalHandler;
|
|
@@ -219,8 +332,12 @@ export class WorkspaceTools {
|
|
|
219
332
|
async execute(call) {
|
|
220
333
|
try {
|
|
221
334
|
switch (call.name) {
|
|
335
|
+
case "update_todo":
|
|
336
|
+
return this.updateTodo(call.arguments);
|
|
222
337
|
case "list_files":
|
|
223
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));
|
|
224
341
|
case "read_file":
|
|
225
342
|
return await this.readFile(readString(call.arguments.path, ""));
|
|
226
343
|
case "read_range":
|
|
@@ -230,17 +347,37 @@ export class WorkspaceTools {
|
|
|
230
347
|
case "search_text":
|
|
231
348
|
return await this.searchText(readString(call.arguments.query, ""));
|
|
232
349
|
case "inspect_document":
|
|
233
|
-
return await this.inspectDocument(readString(call.arguments.path, ""));
|
|
350
|
+
return await this.inspectDocument(readString(call.arguments.path, ""), readString(call.arguments.mode, "auto"));
|
|
351
|
+
case "memory_remember":
|
|
352
|
+
return await this.memoryRemember(readString(call.arguments.content, ""), readStringArray(call.arguments.tags));
|
|
353
|
+
case "memory_search":
|
|
354
|
+
return await this.memorySearch(readString(call.arguments.query, ""), readNumber(call.arguments.limit, 8));
|
|
234
355
|
case "git_status":
|
|
235
356
|
return await this.gitStatus();
|
|
236
357
|
case "git_diff":
|
|
237
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, ""));
|
|
238
363
|
case "list_changed_files":
|
|
239
364
|
return await this.listChangedFiles();
|
|
240
365
|
case "list_scripts":
|
|
241
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();
|
|
242
373
|
case "write_file":
|
|
243
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, ""));
|
|
244
381
|
case "apply_patch":
|
|
245
382
|
return await this.applyPatch(readString(call.arguments.patch, ""));
|
|
246
383
|
case "run_script":
|
|
@@ -257,6 +394,18 @@ export class WorkspaceTools {
|
|
|
257
394
|
return denied(error instanceof Error ? error.message : String(error));
|
|
258
395
|
}
|
|
259
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
|
+
}
|
|
260
409
|
resolveInsideWorkspace(requestedPath) {
|
|
261
410
|
const workspaceRelativePath = this.normalizeWorkspaceRelativePath(requestedPath);
|
|
262
411
|
const absolutePath = path.resolve(this.root, workspaceRelativePath);
|
|
@@ -301,6 +450,35 @@ export class WorkspaceTools {
|
|
|
301
450
|
category: toolSpecs.list_files.category
|
|
302
451
|
};
|
|
303
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
|
+
}
|
|
304
482
|
async readFile(requestedPath) {
|
|
305
483
|
if (!requestedPath) {
|
|
306
484
|
return denied("read_file requires a path.");
|
|
@@ -321,10 +499,11 @@ export class WorkspaceTools {
|
|
|
321
499
|
const clippedContent = clip(content, 20_000);
|
|
322
500
|
return {
|
|
323
501
|
ok: true,
|
|
324
|
-
summary: `read ${
|
|
502
|
+
summary: `read ${normalizeRelative(this.root, absolutePath)}`,
|
|
325
503
|
content: clippedContent,
|
|
326
504
|
tool: "read_file",
|
|
327
|
-
category: toolSpecs.read_file.category
|
|
505
|
+
category: toolSpecs.read_file.category,
|
|
506
|
+
metadata: textContentMetadata(content)
|
|
328
507
|
};
|
|
329
508
|
}
|
|
330
509
|
async readRange(requestedPath, startLine, endLine) {
|
|
@@ -349,12 +528,12 @@ export class WorkspaceTools {
|
|
|
349
528
|
const numberedLines = selectedLines.map((line, index) => `${startLine + index}: ${line}`).join("\n");
|
|
350
529
|
return {
|
|
351
530
|
ok: true,
|
|
352
|
-
summary: `read ${
|
|
531
|
+
summary: `read ${normalizeRelative(this.root, absolutePath)}:${startLine}-${Math.min(endLine, lines.length)}`,
|
|
353
532
|
content: clip(numberedLines || "No lines in range.", 20_000),
|
|
354
533
|
tool: "read_range",
|
|
355
534
|
category: toolSpecs.read_range.category,
|
|
356
535
|
metadata: {
|
|
357
|
-
path:
|
|
536
|
+
path: normalizeRelative(this.root, absolutePath),
|
|
358
537
|
startLine,
|
|
359
538
|
endLine: Math.min(endLine, lines.length)
|
|
360
539
|
}
|
|
@@ -369,7 +548,7 @@ export class WorkspaceTools {
|
|
|
369
548
|
}
|
|
370
549
|
const absolutePath = await this.resolveReadPath(requestedPath);
|
|
371
550
|
const fileStat = await stat(absolutePath);
|
|
372
|
-
const relativePath =
|
|
551
|
+
const relativePath = normalizeRelative(this.root, absolutePath);
|
|
373
552
|
return {
|
|
374
553
|
ok: true,
|
|
375
554
|
summary: `inspected ${relativePath}`,
|
|
@@ -388,7 +567,7 @@ export class WorkspaceTools {
|
|
|
388
567
|
}
|
|
389
568
|
};
|
|
390
569
|
}
|
|
391
|
-
async inspectDocument(requestedPath) {
|
|
570
|
+
async inspectDocument(requestedPath, mode) {
|
|
392
571
|
if (!requestedPath) {
|
|
393
572
|
return denied("inspect_document requires a path.");
|
|
394
573
|
}
|
|
@@ -398,19 +577,141 @@ export class WorkspaceTools {
|
|
|
398
577
|
if (isSensitivePath(requestedPath)) {
|
|
399
578
|
return denied(`inspect_document denied sensitive path: ${requestedPath}`);
|
|
400
579
|
}
|
|
401
|
-
const absolutePath = await this.
|
|
580
|
+
const { absolutePath, external } = await this.resolveDocumentPath(requestedPath);
|
|
581
|
+
if (external) {
|
|
582
|
+
const approval = await this.requestApproval("inspect_document", "external_file", {
|
|
583
|
+
path: absolutePath
|
|
584
|
+
}, `Inspect external file: ${absolutePath}`);
|
|
585
|
+
if (approval.decision === "deny") {
|
|
586
|
+
return denied("inspect_document denied by permission policy.", "inspect_document", approval);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
402
589
|
const extension = path.extname(absolutePath).toLowerCase();
|
|
403
590
|
if (isLikelyTextFile(absolutePath)) {
|
|
404
|
-
return await this.
|
|
591
|
+
return await this.readTextDocument(absolutePath);
|
|
405
592
|
}
|
|
593
|
+
const normalizedMode = normalizeDocumentInspectionMode(mode);
|
|
594
|
+
const wantsLocalOnly = normalizedMode === "local" || normalizedMode === "ocr";
|
|
406
595
|
if (extension === ".pdf") {
|
|
407
|
-
|
|
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);
|
|
408
605
|
}
|
|
409
606
|
if (extension === ".docx") {
|
|
410
|
-
|
|
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);
|
|
627
|
+
}
|
|
628
|
+
if (isImageFile(absolutePath)) {
|
|
629
|
+
return await inspectImageFile(absolutePath, wantsLocalOnly ? undefined : this.documentAnalyzer, this.signal, normalizedMode, this.providerAnalysisTimeoutMs());
|
|
411
630
|
}
|
|
412
631
|
return denied(`inspect_document does not support ${extension || "this file type"} yet.`);
|
|
413
632
|
}
|
|
633
|
+
async readTextDocument(absolutePath) {
|
|
634
|
+
const content = await readFile(absolutePath, "utf8");
|
|
635
|
+
const rawRelativePath = path.relative(this.root, absolutePath);
|
|
636
|
+
const relativePath = normalizeRelative(this.root, absolutePath);
|
|
637
|
+
return {
|
|
638
|
+
ok: true,
|
|
639
|
+
summary: `inspected ${rawRelativePath.startsWith("..") || path.isAbsolute(rawRelativePath) ? absolutePath : relativePath}`,
|
|
640
|
+
content: clip(content, 20_000),
|
|
641
|
+
tool: "inspect_document",
|
|
642
|
+
category: toolSpecs.inspect_document.category,
|
|
643
|
+
metadata: textContentMetadata(content)
|
|
644
|
+
};
|
|
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
|
+
}
|
|
671
|
+
async memoryRemember(content, tags) {
|
|
672
|
+
if (!this.memoryEnabled) {
|
|
673
|
+
return denied("memory_remember requires /experimental memory.", "memory_remember");
|
|
674
|
+
}
|
|
675
|
+
if (!this.allowWrite) {
|
|
676
|
+
const approval = await this.requestApproval("memory_remember", "write", {
|
|
677
|
+
contentLength: content.length,
|
|
678
|
+
tags
|
|
679
|
+
}, `Store durable memory (${content.length} characters).`);
|
|
680
|
+
if (approval.decision === "deny") {
|
|
681
|
+
return denied("memory_remember denied by permission policy.", "memory_remember", approval);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
const store = new MemoryStore();
|
|
686
|
+
const entry = store.remember(this.root, content, tags);
|
|
687
|
+
store.close();
|
|
688
|
+
return {
|
|
689
|
+
ok: true,
|
|
690
|
+
summary: `remembered memory #${entry.id}`,
|
|
691
|
+
content: `Stored memory #${entry.id}: ${entry.content}`,
|
|
692
|
+
tool: "memory_remember",
|
|
693
|
+
category: toolSpecs.memory_remember.category
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
return denied(error instanceof Error ? error.message : String(error), "memory_remember");
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async memorySearch(query, limit) {
|
|
701
|
+
if (!this.memoryEnabled) {
|
|
702
|
+
return denied("memory_search requires /experimental memory.", "memory_search");
|
|
703
|
+
}
|
|
704
|
+
const store = new MemoryStore();
|
|
705
|
+
const matches = store.search(this.root, query, limit);
|
|
706
|
+
store.close();
|
|
707
|
+
return {
|
|
708
|
+
ok: true,
|
|
709
|
+
summary: `found ${matches.length} memory match${matches.length === 1 ? "" : "es"}`,
|
|
710
|
+
content: matches.map((match) => `#${match.id} score ${match.score} ${match.createdAt}\n${match.content}`).join("\n\n") || "No matching memories.",
|
|
711
|
+
tool: "memory_search",
|
|
712
|
+
category: toolSpecs.memory_search.category
|
|
713
|
+
};
|
|
714
|
+
}
|
|
414
715
|
async searchText(query) {
|
|
415
716
|
if (!query.trim()) {
|
|
416
717
|
return denied("search_text requires a non-empty query.");
|
|
@@ -456,25 +757,170 @@ export class WorkspaceTools {
|
|
|
456
757
|
if (isSensitivePath(requestedPath)) {
|
|
457
758
|
return denied(`write_file denied sensitive path: ${requestedPath}`);
|
|
458
759
|
}
|
|
760
|
+
const absolutePath = await this.resolveWritePath(requestedPath);
|
|
761
|
+
const normalized = normalizePossiblyEscapedFileContent(content, absolutePath);
|
|
459
762
|
if (!this.allowWrite) {
|
|
460
763
|
const approval = await this.requestApproval("write_file", "write", {
|
|
461
764
|
path: requestedPath,
|
|
462
|
-
contentLength: content.length
|
|
463
|
-
}, `Write ${requestedPath} (${content.length} characters).`);
|
|
765
|
+
contentLength: normalized.content.length
|
|
766
|
+
}, `Write ${requestedPath} (${normalized.content.length} characters).`);
|
|
464
767
|
if (approval.decision === "deny") {
|
|
465
768
|
return denied("write_file denied by permission policy. Restart with --apply or approve the request in build mode.", "write_file", approval);
|
|
466
769
|
}
|
|
467
770
|
}
|
|
468
|
-
const absolutePath = await this.resolveWritePath(requestedPath);
|
|
469
771
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
470
|
-
await writeFile(absolutePath, content, "utf8");
|
|
772
|
+
await writeFile(absolutePath, normalized.content, "utf8");
|
|
471
773
|
return {
|
|
472
774
|
ok: true,
|
|
473
|
-
summary: `wrote ${
|
|
474
|
-
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." : ""}`,
|
|
475
777
|
tool: "write_file",
|
|
476
778
|
category: toolSpecs.write_file.category,
|
|
477
|
-
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)}`
|
|
478
924
|
};
|
|
479
925
|
}
|
|
480
926
|
async gitStatus() {
|
|
@@ -514,6 +960,48 @@ export class WorkspaceTools {
|
|
|
514
960
|
category: toolSpecs.git_diff.category
|
|
515
961
|
};
|
|
516
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
|
+
}
|
|
517
1005
|
async listChangedFiles() {
|
|
518
1006
|
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
|
|
519
1007
|
cwd: this.root,
|
|
@@ -548,13 +1036,95 @@ export class WorkspaceTools {
|
|
|
548
1036
|
category: toolSpecs.list_scripts.category
|
|
549
1037
|
};
|
|
550
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
|
+
}
|
|
551
1116
|
async applyPatch(patchContent) {
|
|
552
1117
|
if (!patchContent.trim()) {
|
|
553
1118
|
return denied("apply_patch requires a unified patch.", "apply_patch");
|
|
554
1119
|
}
|
|
1120
|
+
const validationError = await this.validatePatchTargets(patchContent);
|
|
1121
|
+
if (validationError) {
|
|
1122
|
+
return denied(validationError, "apply_patch");
|
|
1123
|
+
}
|
|
555
1124
|
if (!this.allowWrite) {
|
|
556
1125
|
const approval = await this.requestApproval("apply_patch", "write", {
|
|
557
|
-
patch: clip(patchContent, 1200)
|
|
1126
|
+
patch: clip(patchContent, 1200),
|
|
1127
|
+
patchHash: stableHash(patchContent)
|
|
558
1128
|
}, previewPatch(patchContent));
|
|
559
1129
|
if (approval.decision === "deny") {
|
|
560
1130
|
return denied("apply_patch denied by permission policy.", "apply_patch", approval);
|
|
@@ -571,22 +1141,33 @@ export class WorkspaceTools {
|
|
|
571
1141
|
};
|
|
572
1142
|
}
|
|
573
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) {
|
|
574
1150
|
const normalizedScript = scriptName.trim();
|
|
575
1151
|
if (!/^[\w:.-]+$/.test(normalizedScript)) {
|
|
576
|
-
return denied(
|
|
1152
|
+
return denied(`${tool} requires a package script name such as test or build.`, tool);
|
|
577
1153
|
}
|
|
578
1154
|
const scripts = await this.readPackageScripts();
|
|
579
1155
|
if (!scripts[normalizedScript]) {
|
|
580
|
-
return denied(`package script not found: ${normalizedScript}`,
|
|
1156
|
+
return denied(`package script not found: ${normalizedScript}`, tool);
|
|
581
1157
|
}
|
|
582
|
-
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");
|
|
583
1164
|
if (!this.allowShell) {
|
|
584
|
-
const approval = await this.requestApproval(
|
|
1165
|
+
const approval = await this.requestApproval(tool, "shell", {
|
|
585
1166
|
script: normalizedScript,
|
|
586
|
-
command:
|
|
587
|
-
},
|
|
1167
|
+
command: approvalCommand
|
|
1168
|
+
}, previewPackageScriptSequence(normalizedScript, scriptCommands, this.root));
|
|
588
1169
|
if (approval.decision === "deny") {
|
|
589
|
-
return denied(
|
|
1170
|
+
return denied(`${tool} denied by permission policy.`, tool, approval);
|
|
590
1171
|
}
|
|
591
1172
|
}
|
|
592
1173
|
const output = await runCommand(`npm run ${normalizedScript}`, this.root, this.timeoutMs, this.signal);
|
|
@@ -594,36 +1175,34 @@ export class WorkspaceTools {
|
|
|
594
1175
|
ok: output.exitCode === 0,
|
|
595
1176
|
summary: `npm run ${normalizedScript} exited ${output.exitCode}`,
|
|
596
1177
|
content: clip(output.output, 20_000),
|
|
597
|
-
tool
|
|
598
|
-
category: toolSpecs.
|
|
599
|
-
preview:
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
async runTests() {
|
|
603
|
-
const scripts = await this.readPackageScripts();
|
|
604
|
-
if (!scripts.test) {
|
|
605
|
-
return denied("No package test script found.", "run_tests");
|
|
606
|
-
}
|
|
607
|
-
const result = await this.runScript("test");
|
|
608
|
-
return {
|
|
609
|
-
...result,
|
|
610
|
-
tool: "run_tests",
|
|
611
|
-
category: toolSpecs.run_tests.category,
|
|
612
|
-
preview: "npm test"
|
|
1178
|
+
tool,
|
|
1179
|
+
category: toolSpecs[tool].category,
|
|
1180
|
+
preview: previewPackageScriptSequence(normalizedScript, scriptCommands, this.root)
|
|
613
1181
|
};
|
|
614
1182
|
}
|
|
615
1183
|
async runShell(command) {
|
|
616
1184
|
if (!command.trim()) {
|
|
617
1185
|
return denied("run_shell requires a command.");
|
|
618
1186
|
}
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
1187
|
+
const shellSafety = validateShellCommand(command, this.root, {
|
|
1188
|
+
allowMetacharacters: this.allowShellMetacharacters
|
|
1189
|
+
});
|
|
1190
|
+
if (shellSafety.error) {
|
|
1191
|
+
return denied(`run_shell denied. ${shellSafety.error}`);
|
|
622
1192
|
}
|
|
623
|
-
|
|
1193
|
+
const shellPathError = await this.validateShellPathArguments(command);
|
|
1194
|
+
if (shellPathError) {
|
|
1195
|
+
return denied(`run_shell denied. ${shellPathError}`);
|
|
1196
|
+
}
|
|
1197
|
+
if (!this.allowShell || shellSafety.requiresApprovalReason) {
|
|
624
1198
|
const approval = await this.requestApproval("run_shell", "shell", {
|
|
625
|
-
command
|
|
626
|
-
|
|
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
|
+
});
|
|
627
1206
|
if (approval.decision === "deny") {
|
|
628
1207
|
return denied("run_shell denied by permission policy.", "run_shell", approval);
|
|
629
1208
|
}
|
|
@@ -639,11 +1218,14 @@ export class WorkspaceTools {
|
|
|
639
1218
|
};
|
|
640
1219
|
}
|
|
641
1220
|
async readPackageScripts() {
|
|
642
|
-
const
|
|
643
|
-
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
1221
|
+
const packageJson = await this.readPackageJsonObject();
|
|
644
1222
|
return Object.fromEntries(Object.entries(packageJson.scripts ?? {}).filter((entry) => typeof entry[1] === "string"));
|
|
645
1223
|
}
|
|
646
|
-
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 = {}) {
|
|
647
1229
|
const spec = getToolSpec(tool);
|
|
648
1230
|
const request = {
|
|
649
1231
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
@@ -651,9 +1233,11 @@ export class WorkspaceTools {
|
|
|
651
1233
|
permission,
|
|
652
1234
|
risk: spec.risk,
|
|
653
1235
|
preview,
|
|
654
|
-
arguments: args
|
|
1236
|
+
arguments: args,
|
|
1237
|
+
bypassable: options.bypassable
|
|
655
1238
|
};
|
|
656
|
-
|
|
1239
|
+
const approvalKey = approvalScopeKey(tool, permission, args);
|
|
1240
|
+
if (this.sessionApprovals.has(approvalKey)) {
|
|
657
1241
|
return {
|
|
658
1242
|
request,
|
|
659
1243
|
decision: "allow_session"
|
|
@@ -667,7 +1251,7 @@ export class WorkspaceTools {
|
|
|
667
1251
|
}
|
|
668
1252
|
const decision = await this.approvalHandler(request);
|
|
669
1253
|
if (decision === "allow_session") {
|
|
670
|
-
this.sessionApprovals.add(
|
|
1254
|
+
this.sessionApprovals.add(approvalKey);
|
|
671
1255
|
}
|
|
672
1256
|
return {
|
|
673
1257
|
request,
|
|
@@ -679,24 +1263,119 @@ export class WorkspaceTools {
|
|
|
679
1263
|
const resolvedPath = await realpath(absolutePath).catch((error) => {
|
|
680
1264
|
throw new Error(`file not found or unreadable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
|
|
681
1265
|
});
|
|
682
|
-
await
|
|
1266
|
+
await this.assertSafeResolvedWorkspacePath(resolvedPath, requestedPath);
|
|
683
1267
|
return resolvedPath;
|
|
684
1268
|
}
|
|
1269
|
+
async resolveDocumentPath(requestedPath) {
|
|
1270
|
+
const trimmedPath = requestedPath.trim();
|
|
1271
|
+
if (!path.isAbsolute(trimmedPath)) {
|
|
1272
|
+
return {
|
|
1273
|
+
absolutePath: await this.resolveReadPath(trimmedPath),
|
|
1274
|
+
external: false
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
if (isSensitivePath(trimmedPath)) {
|
|
1278
|
+
throw new Error(`inspect_document denied sensitive path: ${requestedPath}`);
|
|
1279
|
+
}
|
|
1280
|
+
const relativePath = path.relative(this.root, trimmedPath);
|
|
1281
|
+
if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
|
|
1282
|
+
return {
|
|
1283
|
+
absolutePath: await this.resolveReadPath(trimmedPath),
|
|
1284
|
+
external: false
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
if (!this.allowExternalFileAnalysis) {
|
|
1288
|
+
throw new Error(`Path escapes workspace: ${requestedPath}. Enable /experimental file-analysis to inspect external files.`);
|
|
1289
|
+
}
|
|
1290
|
+
const extension = path.extname(trimmedPath).toLowerCase();
|
|
1291
|
+
if (!isLikelyTextFile(trimmedPath) && extension !== ".pdf" && extension !== ".docx" && extension !== ".doc" && !isImageFile(trimmedPath)) {
|
|
1292
|
+
throw new Error(`external file analysis does not support ${extension || "this file type"} yet.`);
|
|
1293
|
+
}
|
|
1294
|
+
const resolvedPath = await realpath(trimmedPath).catch((error) => {
|
|
1295
|
+
throw new Error(`file not found or unreadable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
|
|
1296
|
+
});
|
|
1297
|
+
if (isSensitivePath(resolvedPath)) {
|
|
1298
|
+
throw new Error(`inspect_document denied sensitive path: ${requestedPath}`);
|
|
1299
|
+
}
|
|
1300
|
+
return {
|
|
1301
|
+
absolutePath: resolvedPath,
|
|
1302
|
+
external: true
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
685
1305
|
async resolveWritePath(requestedPath) {
|
|
686
1306
|
const absolutePath = this.resolveInsideWorkspace(requestedPath);
|
|
687
1307
|
const rootRealPath = await this.rootRealPath;
|
|
688
1308
|
const existingParent = await findNearestExistingParent(absolutePath);
|
|
689
1309
|
const parentRealPath = await realpath(existingParent);
|
|
690
1310
|
await assertInsideWorkspace(rootRealPath, parentRealPath, requestedPath);
|
|
1311
|
+
assertNotSensitiveResolvedPath(rootRealPath, parentRealPath, requestedPath);
|
|
691
1312
|
const targetStat = await lstat(absolutePath).catch(() => null);
|
|
692
1313
|
if (targetStat) {
|
|
693
1314
|
const resolvedTargetPath = await realpath(absolutePath).catch((error) => {
|
|
694
1315
|
throw new Error(`file not writable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
|
|
695
1316
|
});
|
|
696
|
-
await
|
|
1317
|
+
await this.assertSafeResolvedWorkspacePath(resolvedTargetPath, requestedPath);
|
|
697
1318
|
}
|
|
698
1319
|
return absolutePath;
|
|
699
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
|
+
}
|
|
700
1379
|
}
|
|
701
1380
|
async function walkFiles(startPath, workspaceRoot, workspaceRealRoot, maxDepth, maxEntries) {
|
|
702
1381
|
const results = [];
|
|
@@ -759,7 +1438,18 @@ async function searchTextWithRipgrep(workspaceRoot, query, timeoutMs, signal) {
|
|
|
759
1438
|
"!**/.netrc",
|
|
760
1439
|
"!**/id_rsa",
|
|
761
1440
|
"!**/id_ed25519",
|
|
762
|
-
"!**/known_hosts"
|
|
1441
|
+
"!**/known_hosts",
|
|
1442
|
+
"!**/Cookies",
|
|
1443
|
+
"!**/Network/Cookies",
|
|
1444
|
+
"!**/Login Data",
|
|
1445
|
+
"!**/Web Data",
|
|
1446
|
+
"!**/Chrome/**",
|
|
1447
|
+
"!**/Chromium/**",
|
|
1448
|
+
"!**/Brave*/**",
|
|
1449
|
+
"!**/Microsoft Edge/**",
|
|
1450
|
+
"!**/Arc/**",
|
|
1451
|
+
"!**/Firefox/**",
|
|
1452
|
+
"!**/Safari/**"
|
|
763
1453
|
];
|
|
764
1454
|
return new Promise((resolve) => {
|
|
765
1455
|
const child = spawn("rg", [
|
|
@@ -829,6 +1519,15 @@ async function assertInsideWorkspace(workspaceRealRoot, candidatePath, requested
|
|
|
829
1519
|
throw new Error(`Path escapes workspace: ${requestedPath}`);
|
|
830
1520
|
}
|
|
831
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
|
+
}
|
|
832
1531
|
async function findNearestExistingParent(absolutePath) {
|
|
833
1532
|
let currentPath = path.dirname(absolutePath);
|
|
834
1533
|
while (true) {
|
|
@@ -913,6 +1612,73 @@ function runGitApply(patchContent, cwd, timeoutMs, signal) {
|
|
|
913
1612
|
function readString(value, fallback) {
|
|
914
1613
|
return typeof value === "string" ? value : fallback;
|
|
915
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
|
+
}
|
|
916
1682
|
function readNumber(value, fallback) {
|
|
917
1683
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
918
1684
|
return Math.trunc(value);
|
|
@@ -925,11 +1691,20 @@ function readNumber(value, fallback) {
|
|
|
925
1691
|
}
|
|
926
1692
|
return fallback;
|
|
927
1693
|
}
|
|
1694
|
+
function readStringArray(value) {
|
|
1695
|
+
if (Array.isArray(value)) {
|
|
1696
|
+
return value.filter((item) => typeof item === "string");
|
|
1697
|
+
}
|
|
1698
|
+
if (typeof value === "string") {
|
|
1699
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1700
|
+
}
|
|
1701
|
+
return [];
|
|
1702
|
+
}
|
|
928
1703
|
function isPlaceholderPath(value) {
|
|
929
1704
|
const normalizedValue = value.trim().toLowerCase().replaceAll("\\", "/");
|
|
930
1705
|
return ["relative/path", "path/to/file", "file/path", "<path>", "<file>", "filename"].includes(normalizedValue);
|
|
931
1706
|
}
|
|
932
|
-
function isSensitivePath(value) {
|
|
1707
|
+
export function isSensitivePath(value) {
|
|
933
1708
|
const normalizedPath = value.trim().replaceAll("\\", "/");
|
|
934
1709
|
return normalizedPath
|
|
935
1710
|
.split("/")
|
|
@@ -943,7 +1718,7 @@ function isSensitivePath(value) {
|
|
|
943
1718
|
normalizedPart.endsWith(".pfx") ||
|
|
944
1719
|
normalizedPart.startsWith("secrets.") ||
|
|
945
1720
|
normalizedPart.includes("credentials"));
|
|
946
|
-
});
|
|
1721
|
+
}) || blockedPathPatterns.some((pattern) => pattern.test(normalizedPath));
|
|
947
1722
|
}
|
|
948
1723
|
function denied(message, tool, approval) {
|
|
949
1724
|
return {
|
|
@@ -958,6 +1733,158 @@ function denied(message, tool, approval) {
|
|
|
958
1733
|
function isLikelyTextFile(filePath) {
|
|
959
1734
|
return textFileExtensions.has(path.extname(filePath).toLowerCase());
|
|
960
1735
|
}
|
|
1736
|
+
function isImageFile(filePath) {
|
|
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";
|
|
1742
|
+
}
|
|
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) {
|
|
1766
|
+
const buffer = await readFile(filePath);
|
|
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
|
+
}
|
|
1812
|
+
return {
|
|
1813
|
+
ok: true,
|
|
1814
|
+
summary: `inspected image ${path.basename(filePath)}`,
|
|
1815
|
+
content: metadata.join("\n"),
|
|
1816
|
+
tool: "inspect_document",
|
|
1817
|
+
category: toolSpecs.inspect_document.category
|
|
1818
|
+
};
|
|
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
|
+
}
|
|
1853
|
+
function readImageDimensions(buffer, extension) {
|
|
1854
|
+
if (extension === ".png" && buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
1855
|
+
return {
|
|
1856
|
+
width: buffer.readUInt32BE(16),
|
|
1857
|
+
height: buffer.readUInt32BE(20)
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
if ((extension === ".jpg" || extension === ".jpeg") && buffer.length >= 4) {
|
|
1861
|
+
let offset = 2;
|
|
1862
|
+
while (offset + 9 < buffer.length) {
|
|
1863
|
+
if (buffer[offset] !== 0xff) {
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
const marker = buffer[offset + 1];
|
|
1867
|
+
const length = buffer.readUInt16BE(offset + 2);
|
|
1868
|
+
if (marker >= 0xc0 && marker <= 0xc3) {
|
|
1869
|
+
return {
|
|
1870
|
+
height: buffer.readUInt16BE(offset + 5),
|
|
1871
|
+
width: buffer.readUInt16BE(offset + 7)
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
offset += 2 + length;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
if (extension === ".webp" && buffer.length >= 30 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
1878
|
+
const chunk = buffer.subarray(12, 16).toString("ascii");
|
|
1879
|
+
if (chunk === "VP8X") {
|
|
1880
|
+
return {
|
|
1881
|
+
width: 1 + buffer.readUIntLE(24, 3),
|
|
1882
|
+
height: 1 + buffer.readUIntLE(27, 3)
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
961
1888
|
async function extractPdfText(filePath, timeoutMs, signal) {
|
|
962
1889
|
try {
|
|
963
1890
|
const { stdout } = await execFileAsync("pdftotext", ["-layout", filePath, "-"], {
|
|
@@ -976,6 +1903,38 @@ async function extractPdfText(filePath, timeoutMs, signal) {
|
|
|
976
1903
|
return denied(`PDF text extraction needs pdftotext on PATH or a text-based PDF. ${error instanceof Error ? error.message : String(error)}`);
|
|
977
1904
|
}
|
|
978
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
|
+
}
|
|
979
1938
|
async function extractDocxText(filePath) {
|
|
980
1939
|
try {
|
|
981
1940
|
const archive = await readFile(filePath);
|
|
@@ -991,6 +1950,147 @@ async function extractDocxText(filePath) {
|
|
|
991
1950
|
return denied(`DOCX text extraction needs unzip on PATH and a valid .docx file. ${error instanceof Error ? error.message : String(error)}`);
|
|
992
1951
|
}
|
|
993
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
|
+
}
|
|
994
2094
|
function readZipEntryText(archive, entryName) {
|
|
995
2095
|
const endOfCentralDirectoryOffset = findEndOfCentralDirectory(archive);
|
|
996
2096
|
if (endOfCentralDirectoryOffset < 0) {
|
|
@@ -1063,55 +2163,406 @@ function clip(content, maxLength) {
|
|
|
1063
2163
|
return `${content.slice(0, maxLength)}\n...[clipped ${content.length - maxLength} chars]`;
|
|
1064
2164
|
}
|
|
1065
2165
|
function previewPatch(patchContent) {
|
|
1066
|
-
const changedFiles = patchContent
|
|
1067
|
-
.split(/\r?\n/)
|
|
1068
|
-
.filter((line) => line.startsWith("+++ ") || line.startsWith("--- "))
|
|
1069
|
-
.map((line) => line.slice(4).replace(/^a\//, "").replace(/^b\//, ""))
|
|
1070
|
-
.filter((file) => file !== "/dev/null");
|
|
2166
|
+
const changedFiles = extractPatchTargetPaths(patchContent);
|
|
1071
2167
|
const uniqueFiles = [...new Set(changedFiles)].slice(0, 6);
|
|
1072
2168
|
const fileSummary = uniqueFiles.length > 0 ? uniqueFiles.join(", ") : "unknown files";
|
|
1073
2169
|
const added = patchContent.split(/\r?\n/).filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
|
|
1074
2170
|
const removed = patchContent.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
|
|
1075
2171
|
return `Apply patch to ${fileSummary} (+${added}/-${removed}).`;
|
|
1076
2172
|
}
|
|
1077
|
-
function
|
|
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
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
return trimmedValue;
|
|
2204
|
+
}
|
|
2205
|
+
function validateShellCommand(command, workspaceRoot, options) {
|
|
1078
2206
|
const trimmedCommand = command.trim();
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
+
}
|
|
1081
2214
|
}
|
|
1082
|
-
const tokens =
|
|
2215
|
+
const tokens = syntax.tokens;
|
|
1083
2216
|
if (tokens.length === 0) {
|
|
1084
|
-
return "command is empty.";
|
|
2217
|
+
return { error: "command is empty." };
|
|
2218
|
+
}
|
|
2219
|
+
for (const segment of splitShellCommandSegments(tokens)) {
|
|
2220
|
+
const segmentError = validateShellSegment(segment);
|
|
2221
|
+
if (segmentError) {
|
|
2222
|
+
return { error: segmentError };
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
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 !== "||")) {
|
|
2284
|
+
const normalizedToken = stripQuotes(token);
|
|
2285
|
+
if (isSensitivePath(normalizedToken)) {
|
|
2286
|
+
return "sensitive path arguments are blocked.";
|
|
2287
|
+
}
|
|
2288
|
+
if (/(^|[\\/])\.\.([\\/]|$)/.test(normalizedToken)) {
|
|
2289
|
+
return "parent directory traversal is blocked.";
|
|
2290
|
+
}
|
|
2291
|
+
const absolutePath = toAbsoluteShellPath(normalizedToken);
|
|
2292
|
+
if (absolutePath) {
|
|
2293
|
+
const relativePath = path.relative(workspaceRoot, absolutePath);
|
|
2294
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
2295
|
+
return "absolute path arguments outside the workspace are blocked.";
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
return null;
|
|
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.";
|
|
1085
2425
|
}
|
|
1086
2426
|
const executable = stripQuotes(tokens[0] ?? "").toLowerCase();
|
|
1087
|
-
const subcommand =
|
|
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
|
+
}
|
|
2442
|
+
function validateShellSegment(tokens) {
|
|
2443
|
+
if (tokens.length === 0) {
|
|
2444
|
+
return "empty shell pipeline segment.";
|
|
2445
|
+
}
|
|
2446
|
+
const executable = stripQuotes(tokens[0] ?? "").toLowerCase();
|
|
2447
|
+
const subcommand = findCommandSubcommand(executable, tokens.slice(1));
|
|
1088
2448
|
if (["bash", "sh", "zsh", "fish", "pwsh", "powershell", "powershell.exe", "python", "python3", "node", "ruby", "perl"].includes(executable)) {
|
|
1089
2449
|
return `executable "${executable}" is blocked.`;
|
|
1090
2450
|
}
|
|
1091
2451
|
if (["rm", "rmdir", "mv", "cp"].includes(executable) && tokens.some((token) => /^-.*[fRr]/.test(stripQuotes(token)))) {
|
|
1092
2452
|
return `destructive ${executable} flags are blocked.`;
|
|
1093
2453
|
}
|
|
1094
|
-
if (executable === "git" && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
|
|
2454
|
+
if (executable === "git" && subcommand && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
|
|
1095
2455
|
return `git ${subcommand} is blocked in the shell tool.`;
|
|
1096
2456
|
}
|
|
1097
|
-
if (executable === "npm" && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
|
|
2457
|
+
if (executable === "npm" && subcommand && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
|
|
1098
2458
|
return `npm ${subcommand} is blocked in the shell tool.`;
|
|
1099
2459
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
function splitShellCommandSegments(tokens) {
|
|
2463
|
+
const segments = [[]];
|
|
2464
|
+
for (const token of tokens) {
|
|
2465
|
+
if (["|", "&&", "||", ";", "&"].includes(token)) {
|
|
2466
|
+
segments.push([]);
|
|
2467
|
+
continue;
|
|
1104
2468
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
2469
|
+
segments.at(-1)?.push(token);
|
|
2470
|
+
}
|
|
2471
|
+
return segments;
|
|
2472
|
+
}
|
|
2473
|
+
function splitPipeline(tokens) {
|
|
2474
|
+
const segments = [[]];
|
|
2475
|
+
for (const token of tokens) {
|
|
2476
|
+
if (token === "|") {
|
|
2477
|
+
segments.push([]);
|
|
2478
|
+
continue;
|
|
1107
2479
|
}
|
|
2480
|
+
segments.at(-1)?.push(token);
|
|
2481
|
+
}
|
|
2482
|
+
return segments;
|
|
2483
|
+
}
|
|
2484
|
+
function findCommandSubcommand(executable, tokens) {
|
|
2485
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
2486
|
+
const token = stripQuotes(tokens[index] ?? "");
|
|
2487
|
+
if (!token || token === "--") {
|
|
2488
|
+
continue;
|
|
2489
|
+
}
|
|
2490
|
+
if (executable === "git" && ["-C", "-c", "--git-dir", "--work-tree"].includes(token)) {
|
|
2491
|
+
index += 1;
|
|
2492
|
+
continue;
|
|
2493
|
+
}
|
|
2494
|
+
if (executable === "npm" && ["--prefix", "--userconfig", "--cache"].includes(token)) {
|
|
2495
|
+
index += 1;
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
2498
|
+
if (token.startsWith("-")) {
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
return token.toLowerCase();
|
|
2502
|
+
}
|
|
2503
|
+
return null;
|
|
2504
|
+
}
|
|
2505
|
+
function toAbsoluteShellPath(value) {
|
|
2506
|
+
if (!value || value === "|" || value.startsWith("-")) {
|
|
2507
|
+
return null;
|
|
2508
|
+
}
|
|
2509
|
+
if (path.isAbsolute(value)) {
|
|
2510
|
+
return path.resolve(value);
|
|
2511
|
+
}
|
|
2512
|
+
if (value === "~" || value.startsWith("~/")) {
|
|
2513
|
+
return path.resolve(homedir(), value === "~" ? "." : value.slice(2));
|
|
1108
2514
|
}
|
|
1109
2515
|
return null;
|
|
1110
2516
|
}
|
|
1111
|
-
function
|
|
1112
|
-
const
|
|
2517
|
+
function previewPackageScriptSequence(name, commands, workspaceRoot) {
|
|
2518
|
+
const commandSummary = commands.map((entry) => `${entry.name}: ${entry.command}`).join(" && ");
|
|
2519
|
+
const risk = validatePackageScriptCommands(commands, workspaceRoot);
|
|
1113
2520
|
const prefix = risk ? `Risky package script (${risk})` : "Run package script";
|
|
1114
|
-
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"));
|
|
1115
2566
|
}
|
|
1116
2567
|
function stripQuotes(value) {
|
|
1117
2568
|
return value.replace(/^['"]|['"]$/g, "");
|