@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.
Files changed (180) hide show
  1. package/.env.example +17 -1
  2. package/README.md +113 -23
  3. package/SECURITY.md +7 -1
  4. package/dist/cli.js +103 -14
  5. package/dist/cli.js.map +1 -1
  6. package/dist/core/agent.d.ts +47 -1
  7. package/dist/core/agent.js +667 -76
  8. package/dist/core/agent.js.map +1 -1
  9. package/dist/core/cleanup.d.ts +3 -0
  10. package/dist/core/cleanup.js +29 -0
  11. package/dist/core/cleanup.js.map +1 -0
  12. package/dist/core/clipboard.d.ts +14 -0
  13. package/dist/core/clipboard.js +134 -0
  14. package/dist/core/clipboard.js.map +1 -0
  15. package/dist/core/codex.d.ts +8 -0
  16. package/dist/core/codex.js +28 -2
  17. package/dist/core/codex.js.map +1 -1
  18. package/dist/core/compaction.d.ts +23 -0
  19. package/dist/core/compaction.js +145 -0
  20. package/dist/core/compaction.js.map +1 -0
  21. package/dist/core/contextFormat.d.ts +21 -0
  22. package/dist/core/contextFormat.js +87 -0
  23. package/dist/core/contextFormat.js.map +1 -0
  24. package/dist/core/contextItem.d.ts +41 -0
  25. package/dist/core/contextItem.js +93 -0
  26. package/dist/core/contextItem.js.map +1 -0
  27. package/dist/core/contextStore.d.ts +48 -0
  28. package/dist/core/contextStore.js +306 -0
  29. package/dist/core/contextStore.js.map +1 -0
  30. package/dist/core/doctor.d.ts +4 -1
  31. package/dist/core/doctor.js +122 -3
  32. package/dist/core/doctor.js.map +1 -1
  33. package/dist/core/gemini.js +10 -4
  34. package/dist/core/gemini.js.map +1 -1
  35. package/dist/core/geminiWrapper.d.ts +92 -0
  36. package/dist/core/geminiWrapper.js +1258 -0
  37. package/dist/core/geminiWrapper.js.map +1 -0
  38. package/dist/core/http.js +70 -6
  39. package/dist/core/http.js.map +1 -1
  40. package/dist/core/json.d.ts +1 -1
  41. package/dist/core/json.js +81 -19
  42. package/dist/core/json.js.map +1 -1
  43. package/dist/core/memory.d.ts +16 -0
  44. package/dist/core/memory.js +108 -0
  45. package/dist/core/memory.js.map +1 -0
  46. package/dist/core/modelClient.js +7 -0
  47. package/dist/core/modelClient.js.map +1 -1
  48. package/dist/core/nvidia.d.ts +1 -1
  49. package/dist/core/nvidia.js +13 -4
  50. package/dist/core/nvidia.js.map +1 -1
  51. package/dist/core/ollama.js +13 -3
  52. package/dist/core/ollama.js.map +1 -1
  53. package/dist/core/openrouter.js +15 -6
  54. package/dist/core/openrouter.js.map +1 -1
  55. package/dist/core/projectInit.d.ts +6 -0
  56. package/dist/core/projectInit.js +44 -0
  57. package/dist/core/projectInit.js.map +1 -0
  58. package/dist/core/reasoning.js +6 -0
  59. package/dist/core/reasoning.js.map +1 -1
  60. package/dist/core/session.d.ts +1 -0
  61. package/dist/core/session.js +55 -3
  62. package/dist/core/session.js.map +1 -1
  63. package/dist/core/tokenAccounting.d.ts +4 -0
  64. package/dist/core/tokenAccounting.js +75 -13
  65. package/dist/core/tokenAccounting.js.map +1 -1
  66. package/dist/core/types.d.ts +65 -5
  67. package/dist/core/types.js +30 -1
  68. package/dist/core/types.js.map +1 -1
  69. package/dist/core/updateCheck.d.ts +19 -0
  70. package/dist/core/updateCheck.js +103 -0
  71. package/dist/core/updateCheck.js.map +1 -0
  72. package/dist/core/workspace.d.ts +37 -0
  73. package/dist/core/workspace.js +1535 -84
  74. package/dist/core/workspace.js.map +1 -1
  75. package/dist/tui/App.d.ts +1 -0
  76. package/dist/tui/App.js +1841 -140
  77. package/dist/tui/App.js.map +1 -1
  78. package/dist/tui/commands.js +141 -9
  79. package/dist/tui/commands.js.map +1 -1
  80. package/dist/tui/components/ApprovalPanel.js +16 -1
  81. package/dist/tui/components/ApprovalPanel.js.map +1 -1
  82. package/dist/tui/components/CommandSuggestions.js +33 -5
  83. package/dist/tui/components/CommandSuggestions.js.map +1 -1
  84. package/dist/tui/components/Composer.d.ts +3 -0
  85. package/dist/tui/components/Composer.js +57 -5
  86. package/dist/tui/components/Composer.js.map +1 -1
  87. package/dist/tui/components/ExperimentalPanel.d.ts +10 -0
  88. package/dist/tui/components/ExperimentalPanel.js +38 -0
  89. package/dist/tui/components/ExperimentalPanel.js.map +1 -0
  90. package/dist/tui/components/Header.js +3 -3
  91. package/dist/tui/components/Header.js.map +1 -1
  92. package/dist/tui/components/OnboardingPanel.d.ts +25 -1
  93. package/dist/tui/components/OnboardingPanel.js +87 -25
  94. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  95. package/dist/tui/components/Sidebar.js +17 -13
  96. package/dist/tui/components/Sidebar.js.map +1 -1
  97. package/dist/tui/components/StartupBanner.d.ts +4 -0
  98. package/dist/tui/components/StartupBanner.js +9 -0
  99. package/dist/tui/components/StartupBanner.js.map +1 -0
  100. package/dist/tui/components/Transcript.d.ts +7 -0
  101. package/dist/tui/components/Transcript.js +87 -17
  102. package/dist/tui/components/Transcript.js.map +1 -1
  103. package/dist/tui/contextCommands.d.ts +8 -0
  104. package/dist/tui/contextCommands.js +205 -0
  105. package/dist/tui/contextCommands.js.map +1 -0
  106. package/dist/tui/experimental/AnimatedText.d.ts +38 -0
  107. package/dist/tui/experimental/AnimatedText.js +55 -0
  108. package/dist/tui/experimental/AnimatedText.js.map +1 -0
  109. package/dist/tui/experimental/Banner.d.ts +10 -0
  110. package/dist/tui/experimental/Banner.js +33 -0
  111. package/dist/tui/experimental/Banner.js.map +1 -0
  112. package/dist/tui/experimental/CommandPalette.d.ts +11 -0
  113. package/dist/tui/experimental/CommandPalette.js +25 -0
  114. package/dist/tui/experimental/CommandPalette.js.map +1 -0
  115. package/dist/tui/experimental/ExperimentalShell.d.ts +58 -0
  116. package/dist/tui/experimental/ExperimentalShell.js +366 -0
  117. package/dist/tui/experimental/ExperimentalShell.js.map +1 -0
  118. package/dist/tui/experimental/ThemePicker.d.ts +13 -0
  119. package/dist/tui/experimental/ThemePicker.js +12 -0
  120. package/dist/tui/experimental/ThemePicker.js.map +1 -0
  121. package/dist/tui/experimental/attachments.d.ts +35 -0
  122. package/dist/tui/experimental/attachments.js +244 -0
  123. package/dist/tui/experimental/attachments.js.map +1 -0
  124. package/dist/tui/experimental/composer.d.ts +24 -0
  125. package/dist/tui/experimental/composer.js +84 -0
  126. package/dist/tui/experimental/composer.js.map +1 -0
  127. package/dist/tui/experimental/geminiPricing.d.ts +16 -0
  128. package/dist/tui/experimental/geminiPricing.js +39 -0
  129. package/dist/tui/experimental/geminiPricing.js.map +1 -0
  130. package/dist/tui/experimental/layout.d.ts +46 -0
  131. package/dist/tui/experimental/layout.js +112 -0
  132. package/dist/tui/experimental/layout.js.map +1 -0
  133. package/dist/tui/experimental/theme.d.ts +35 -0
  134. package/dist/tui/experimental/theme.js +86 -0
  135. package/dist/tui/experimental/theme.js.map +1 -0
  136. package/dist/tui/experimental/transcriptRows.d.ts +20 -0
  137. package/dist/tui/experimental/transcriptRows.js +169 -0
  138. package/dist/tui/experimental/transcriptRows.js.map +1 -0
  139. package/dist/tui/experimental/ultraModes.d.ts +46 -0
  140. package/dist/tui/experimental/ultraModes.js +95 -0
  141. package/dist/tui/experimental/ultraModes.js.map +1 -0
  142. package/dist/tui/experimental/ultramaxx.d.ts +19 -0
  143. package/dist/tui/experimental/ultramaxx.js +43 -0
  144. package/dist/tui/experimental/ultramaxx.js.map +1 -0
  145. package/dist/tui/format.d.ts +4 -2
  146. package/dist/tui/format.js +21 -7
  147. package/dist/tui/format.js.map +1 -1
  148. package/dist/tui/hosts.js +7 -1
  149. package/dist/tui/hosts.js.map +1 -1
  150. package/dist/tui/layout.d.ts +26 -0
  151. package/dist/tui/layout.js +66 -0
  152. package/dist/tui/layout.js.map +1 -0
  153. package/dist/tui/modelSelection.d.ts +1 -1
  154. package/dist/tui/modelSelection.js +8 -6
  155. package/dist/tui/modelSelection.js.map +1 -1
  156. package/dist/tui/modes.d.ts +8 -1
  157. package/dist/tui/modes.js +20 -2
  158. package/dist/tui/modes.js.map +1 -1
  159. package/dist/tui/onboardingPreferences.d.ts +37 -0
  160. package/dist/tui/onboardingPreferences.js +118 -0
  161. package/dist/tui/onboardingPreferences.js.map +1 -0
  162. package/dist/tui/runStatus.d.ts +50 -0
  163. package/dist/tui/runStatus.js +164 -0
  164. package/dist/tui/runStatus.js.map +1 -0
  165. package/dist/tui/types.d.ts +8 -0
  166. package/dist/tui/types.js.map +1 -1
  167. package/docs/architecture.md +115 -0
  168. package/docs/gemini-wrapper.md +110 -0
  169. package/docs/product-context.md +43 -0
  170. package/docs/releases/v0.1.1-beta.md +18 -0
  171. package/docs/releases/v0.2.1.md +1 -1
  172. package/docs/releases/v0.3.1-beta.md +4 -0
  173. package/docs/releases/v0.4.0.md +1 -1
  174. package/docs/releases/v1.0.0.md +28 -0
  175. package/docs/releases/v1.0.1.md +25 -0
  176. package/docs/releases/v1.1.0.md +30 -0
  177. package/docs/releases/v1.2.0.md +28 -0
  178. package/docs/showcase/patchpilot-banner.png +0 -0
  179. package/docs/showcase/patchpilot-logo.png +0 -0
  180. package/package.json +8 -3
@@ -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 ${path.relative(this.root, absolutePath)}`,
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 ${path.relative(this.root, absolutePath)}:${startLine}-${Math.min(endLine, lines.length)}`,
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: path.relative(this.root, absolutePath),
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 = path.relative(this.root, absolutePath);
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.resolveReadPath(requestedPath);
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.readFile(requestedPath);
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
- return await extractPdfText(absolutePath, this.timeoutMs, this.signal);
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
- return await extractDocxText(absolutePath);
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 ${path.relative(this.root, absolutePath)}`,
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 ${path.relative(this.root, absolutePath)}`
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("run_script requires a package script name such as test or build.", "run_script");
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}`, "run_script");
1156
+ return denied(`package script not found: ${normalizedScript}`, tool);
581
1157
  }
582
- const scriptCommand = scripts[normalizedScript];
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("run_script", "shell", {
1165
+ const approval = await this.requestApproval(tool, "shell", {
585
1166
  script: normalizedScript,
586
- command: scriptCommand
587
- }, previewPackageScript(normalizedScript, scriptCommand));
1167
+ command: approvalCommand
1168
+ }, previewPackageScriptSequence(normalizedScript, scriptCommands, this.root));
588
1169
  if (approval.decision === "deny") {
589
- return denied("run_script denied by permission policy.", "run_script", approval);
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: "run_script",
598
- category: toolSpecs.run_script.category,
599
- preview: previewPackageScript(normalizedScript, scriptCommand)
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 shellSafetyError = validateShellCommand(command);
620
- if (shellSafetyError) {
621
- return denied(`run_shell denied. ${shellSafetyError}`);
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
- if (!this.allowShell) {
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
- }, `Run shell command: ${command}`);
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 packageJsonPath = await this.resolveReadPath("package.json");
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 requestApproval(tool, permission, args, preview) {
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
- if (this.sessionApprovals.has(permission)) {
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(permission);
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 assertInsideWorkspace(await this.rootRealPath, resolvedPath, requestedPath);
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 assertInsideWorkspace(rootRealPath, resolvedTargetPath, requestedPath);
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("&", "&amp;")
2090
+ .replaceAll("<", "&lt;")
2091
+ .replaceAll(">", "&gt;")
2092
+ .replaceAll('"', "&quot;");
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 validateShellCommand(command) {
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
- if (/[;&|><`$\n\r]/.test(trimmedCommand)) {
1080
- return "shell metacharacters are blocked; run a single simple command.";
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 = trimmedCommand.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
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 = stripQuotes(tokens[1] ?? "").toLowerCase();
2427
+ const subcommand = findCommandSubcommand(executable, tokens.slice(1));
2428
+ if (["bash", "sh", "zsh", "fish", "pwsh", "powershell", "powershell.exe"].includes(executable)) {
2429
+ return `executable "${executable}" is blocked in package scripts.`;
2430
+ }
2431
+ if (["rm", "rmdir", "mv", "cp"].includes(executable) && tokens.some((token) => /^-.*[fRr]/.test(stripQuotes(token)))) {
2432
+ return `destructive ${executable} flags are blocked.`;
2433
+ }
2434
+ if (executable === "git" && subcommand && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
2435
+ return `git ${subcommand} is blocked in package scripts.`;
2436
+ }
2437
+ if (executable === "npm" && subcommand && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
2438
+ return `npm ${subcommand} is blocked in package scripts.`;
2439
+ }
2440
+ return null;
2441
+ }
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
- for (const token of tokens.slice(1)) {
1101
- const normalizedToken = stripQuotes(token);
1102
- if (normalizedToken.startsWith("/") || normalizedToken.startsWith("~")) {
1103
- return "absolute and home-relative paths are blocked.";
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
- if (/(^|[\\/])\.\.([\\/]|$)/.test(normalizedToken)) {
1106
- return "parent directory traversal is blocked.";
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 previewPackageScript(name, command) {
1112
- const risk = validateShellCommand(command);
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(command, 220)}`;
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, "");