@jx-grxf/patchpilot 0.4.0 → 1.0.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 (72) hide show
  1. package/.env.example +17 -1
  2. package/README.md +69 -14
  3. package/SECURITY.md +7 -1
  4. package/dist/cli.js +59 -13
  5. package/dist/cli.js.map +1 -1
  6. package/dist/core/agent.d.ts +3 -0
  7. package/dist/core/agent.js +56 -12
  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/doctor.d.ts +4 -1
  13. package/dist/core/doctor.js +119 -1
  14. package/dist/core/doctor.js.map +1 -1
  15. package/dist/core/geminiWrapper.d.ts +51 -0
  16. package/dist/core/geminiWrapper.js +718 -0
  17. package/dist/core/geminiWrapper.js.map +1 -0
  18. package/dist/core/json.js +65 -1
  19. package/dist/core/json.js.map +1 -1
  20. package/dist/core/memory.d.ts +16 -0
  21. package/dist/core/memory.js +108 -0
  22. package/dist/core/memory.js.map +1 -0
  23. package/dist/core/modelClient.js +7 -0
  24. package/dist/core/modelClient.js.map +1 -1
  25. package/dist/core/nvidia.js +1 -1
  26. package/dist/core/nvidia.js.map +1 -1
  27. package/dist/core/projectInit.d.ts +6 -0
  28. package/dist/core/projectInit.js +44 -0
  29. package/dist/core/projectInit.js.map +1 -0
  30. package/dist/core/reasoning.js +3 -0
  31. package/dist/core/reasoning.js.map +1 -1
  32. package/dist/core/session.d.ts +1 -0
  33. package/dist/core/session.js +46 -0
  34. package/dist/core/session.js.map +1 -1
  35. package/dist/core/types.d.ts +9 -4
  36. package/dist/core/workspace.d.ts +8 -0
  37. package/dist/core/workspace.js +293 -21
  38. package/dist/core/workspace.js.map +1 -1
  39. package/dist/tui/App.js +536 -69
  40. package/dist/tui/App.js.map +1 -1
  41. package/dist/tui/commands.js +35 -6
  42. package/dist/tui/commands.js.map +1 -1
  43. package/dist/tui/components/CommandSuggestions.js +8 -3
  44. package/dist/tui/components/CommandSuggestions.js.map +1 -1
  45. package/dist/tui/components/Composer.js +1 -1
  46. package/dist/tui/components/Composer.js.map +1 -1
  47. package/dist/tui/components/ExperimentalPanel.d.ts +10 -0
  48. package/dist/tui/components/ExperimentalPanel.js +33 -0
  49. package/dist/tui/components/ExperimentalPanel.js.map +1 -0
  50. package/dist/tui/components/Header.js +3 -3
  51. package/dist/tui/components/Header.js.map +1 -1
  52. package/dist/tui/components/OnboardingPanel.d.ts +13 -1
  53. package/dist/tui/components/OnboardingPanel.js +23 -9
  54. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  55. package/dist/tui/components/Sidebar.js +17 -13
  56. package/dist/tui/components/Sidebar.js.map +1 -1
  57. package/dist/tui/components/Transcript.js +2 -2
  58. package/dist/tui/components/Transcript.js.map +1 -1
  59. package/dist/tui/format.js +7 -7
  60. package/dist/tui/format.js.map +1 -1
  61. package/dist/tui/modes.d.ts +1 -1
  62. package/dist/tui/modes.js +8 -2
  63. package/dist/tui/modes.js.map +1 -1
  64. package/docs/gemini-wrapper.md +87 -0
  65. package/docs/releases/v0.1.1-beta.md +18 -0
  66. package/docs/releases/v0.2.1.md +1 -1
  67. package/docs/releases/v0.3.1-beta.md +4 -0
  68. package/docs/releases/v0.4.0.md +1 -1
  69. package/docs/releases/v1.0.0.md +28 -0
  70. package/docs/showcase/patchpilot-banner.png +0 -0
  71. package/docs/showcase/patchpilot-logo.png +0 -0
  72. package/package.json +5 -2
@@ -3,6 +3,8 @@ export type WorkspaceToolsOptions = {
3
3
  root: string;
4
4
  allowWrite: boolean;
5
5
  allowShell: boolean;
6
+ allowExternalFileAnalysis?: boolean;
7
+ memoryEnabled?: boolean;
6
8
  timeoutMs?: number;
7
9
  signal?: AbortSignal;
8
10
  approvalHandler?: (request: ApprovalRequest) => Promise<PermissionDecision>;
@@ -14,6 +16,8 @@ export declare class WorkspaceTools {
14
16
  private readonly rootRealPath;
15
17
  private readonly allowWrite;
16
18
  private readonly allowShell;
19
+ private readonly allowExternalFileAnalysis;
20
+ private readonly memoryEnabled;
17
21
  private readonly timeoutMs;
18
22
  private readonly signal?;
19
23
  private readonly approvalHandler?;
@@ -27,6 +31,9 @@ export declare class WorkspaceTools {
27
31
  private readRange;
28
32
  private fileInfo;
29
33
  private inspectDocument;
34
+ private readTextDocument;
35
+ private memoryRemember;
36
+ private memorySearch;
30
37
  private searchText;
31
38
  private writeFile;
32
39
  private gitStatus;
@@ -40,5 +47,6 @@ export declare class WorkspaceTools {
40
47
  private readPackageScripts;
41
48
  private requestApproval;
42
49
  private resolveReadPath;
50
+ private resolveDocumentPath;
43
51
  private resolveWritePath;
44
52
  }
@@ -5,6 +5,7 @@ import { platform } from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { inflateRawSync } from "node:zlib";
8
+ import { MemoryStore } from "./memory.js";
8
9
  const execFileAsync = promisify(execFile);
9
10
  const ignoredDirectories = new Set([
10
11
  ".git",
@@ -73,6 +74,11 @@ const blockedPathNames = new Set([
73
74
  "id_dsa",
74
75
  "known_hosts"
75
76
  ]);
77
+ const blockedPathPatterns = [
78
+ /(^|\/)(cookies|network\/cookies|login data|web data)$/i,
79
+ /(^|\/)(chrome|chromium|brave-browser|brave|microsoft edge|edge|arc|firefox|safari)(\/|$)/i,
80
+ /(^|\/)(default|profile \d+|profiles?)\/(cookies|network\/cookies|login data|web data)$/i
81
+ ];
76
82
  export const toolSpecs = {
77
83
  list_files: {
78
84
  name: "list_files",
@@ -122,6 +128,22 @@ export const toolSpecs = {
122
128
  permission: "none",
123
129
  category: "document"
124
130
  },
131
+ memory_remember: {
132
+ name: "memory_remember",
133
+ description: "Store a durable memory for this workspace.",
134
+ risk: "low",
135
+ sideEffects: "write",
136
+ permission: "write",
137
+ category: "memory"
138
+ },
139
+ memory_search: {
140
+ name: "memory_search",
141
+ description: "Search durable workspace memories.",
142
+ risk: "low",
143
+ sideEffects: "none",
144
+ permission: "none",
145
+ category: "memory"
146
+ },
125
147
  git_status: {
126
148
  name: "git_status",
127
149
  description: "Read the current Git branch and dirty state.",
@@ -203,6 +225,8 @@ export class WorkspaceTools {
203
225
  rootRealPath;
204
226
  allowWrite;
205
227
  allowShell;
228
+ allowExternalFileAnalysis;
229
+ memoryEnabled;
206
230
  timeoutMs;
207
231
  signal;
208
232
  approvalHandler;
@@ -212,6 +236,8 @@ export class WorkspaceTools {
212
236
  this.rootRealPath = realpath(this.root).catch(() => this.root);
213
237
  this.allowWrite = options.allowWrite;
214
238
  this.allowShell = options.allowShell;
239
+ this.allowExternalFileAnalysis = Boolean(options.allowExternalFileAnalysis);
240
+ this.memoryEnabled = Boolean(options.memoryEnabled);
215
241
  this.timeoutMs = options.timeoutMs ?? 60_000;
216
242
  this.signal = options.signal;
217
243
  this.approvalHandler = options.approvalHandler;
@@ -231,6 +257,10 @@ export class WorkspaceTools {
231
257
  return await this.searchText(readString(call.arguments.query, ""));
232
258
  case "inspect_document":
233
259
  return await this.inspectDocument(readString(call.arguments.path, ""));
260
+ case "memory_remember":
261
+ return await this.memoryRemember(readString(call.arguments.content, ""), readStringArray(call.arguments.tags));
262
+ case "memory_search":
263
+ return await this.memorySearch(readString(call.arguments.query, ""), readNumber(call.arguments.limit, 8));
234
264
  case "git_status":
235
265
  return await this.gitStatus();
236
266
  case "git_diff":
@@ -398,10 +428,18 @@ export class WorkspaceTools {
398
428
  if (isSensitivePath(requestedPath)) {
399
429
  return denied(`inspect_document denied sensitive path: ${requestedPath}`);
400
430
  }
401
- const absolutePath = await this.resolveReadPath(requestedPath);
431
+ const { absolutePath, external } = await this.resolveDocumentPath(requestedPath);
432
+ if (external) {
433
+ const approval = await this.requestApproval("inspect_document", "external_file", {
434
+ path: absolutePath
435
+ }, `Inspect external file: ${absolutePath}`);
436
+ if (approval.decision === "deny") {
437
+ return denied("inspect_document denied by permission policy.", "inspect_document", approval);
438
+ }
439
+ }
402
440
  const extension = path.extname(absolutePath).toLowerCase();
403
441
  if (isLikelyTextFile(absolutePath)) {
404
- return await this.readFile(requestedPath);
442
+ return await this.readTextDocument(absolutePath);
405
443
  }
406
444
  if (extension === ".pdf") {
407
445
  return await extractPdfText(absolutePath, this.timeoutMs, this.signal);
@@ -409,8 +447,66 @@ export class WorkspaceTools {
409
447
  if (extension === ".docx") {
410
448
  return await extractDocxText(absolutePath);
411
449
  }
450
+ if (isImageFile(absolutePath)) {
451
+ return await inspectImageFile(absolutePath);
452
+ }
412
453
  return denied(`inspect_document does not support ${extension || "this file type"} yet.`);
413
454
  }
455
+ async readTextDocument(absolutePath) {
456
+ const content = await readFile(absolutePath, "utf8");
457
+ const relativePath = path.relative(this.root, absolutePath);
458
+ return {
459
+ ok: true,
460
+ summary: `inspected ${relativePath.startsWith("..") || path.isAbsolute(relativePath) ? absolutePath : relativePath}`,
461
+ content: clip(content, 20_000),
462
+ tool: "inspect_document",
463
+ category: toolSpecs.inspect_document.category
464
+ };
465
+ }
466
+ async memoryRemember(content, tags) {
467
+ if (!this.memoryEnabled) {
468
+ return denied("memory_remember requires /experimental memory.", "memory_remember");
469
+ }
470
+ if (!this.allowWrite) {
471
+ const approval = await this.requestApproval("memory_remember", "write", {
472
+ contentLength: content.length,
473
+ tags
474
+ }, `Store durable memory (${content.length} characters).`);
475
+ if (approval.decision === "deny") {
476
+ return denied("memory_remember denied by permission policy.", "memory_remember", approval);
477
+ }
478
+ }
479
+ try {
480
+ const store = new MemoryStore();
481
+ const entry = store.remember(this.root, content, tags);
482
+ store.close();
483
+ return {
484
+ ok: true,
485
+ summary: `remembered memory #${entry.id}`,
486
+ content: `Stored memory #${entry.id}: ${entry.content}`,
487
+ tool: "memory_remember",
488
+ category: toolSpecs.memory_remember.category
489
+ };
490
+ }
491
+ catch (error) {
492
+ return denied(error instanceof Error ? error.message : String(error), "memory_remember");
493
+ }
494
+ }
495
+ async memorySearch(query, limit) {
496
+ if (!this.memoryEnabled) {
497
+ return denied("memory_search requires /experimental memory.", "memory_search");
498
+ }
499
+ const store = new MemoryStore();
500
+ const matches = store.search(this.root, query, limit);
501
+ store.close();
502
+ return {
503
+ ok: true,
504
+ summary: `found ${matches.length} memory match${matches.length === 1 ? "" : "es"}`,
505
+ content: matches.map((match) => `#${match.id} score ${match.score} ${match.createdAt}\n${match.content}`).join("\n\n") || "No matching memories.",
506
+ tool: "memory_search",
507
+ category: toolSpecs.memory_search.category
508
+ };
509
+ }
414
510
  async searchText(query) {
415
511
  if (!query.trim()) {
416
512
  return denied("search_text requires a non-empty query.");
@@ -584,7 +680,7 @@ export class WorkspaceTools {
584
680
  const approval = await this.requestApproval("run_script", "shell", {
585
681
  script: normalizedScript,
586
682
  command: scriptCommand
587
- }, previewPackageScript(normalizedScript, scriptCommand));
683
+ }, previewPackageScript(normalizedScript, scriptCommand, this.root));
588
684
  if (approval.decision === "deny") {
589
685
  return denied("run_script denied by permission policy.", "run_script", approval);
590
686
  }
@@ -596,7 +692,7 @@ export class WorkspaceTools {
596
692
  content: clip(output.output, 20_000),
597
693
  tool: "run_script",
598
694
  category: toolSpecs.run_script.category,
599
- preview: previewPackageScript(normalizedScript, scriptCommand)
695
+ preview: previewPackageScript(normalizedScript, scriptCommand, this.root)
600
696
  };
601
697
  }
602
698
  async runTests() {
@@ -616,7 +712,7 @@ export class WorkspaceTools {
616
712
  if (!command.trim()) {
617
713
  return denied("run_shell requires a command.");
618
714
  }
619
- const shellSafetyError = validateShellCommand(command);
715
+ const shellSafetyError = validateShellCommand(command, this.root);
620
716
  if (shellSafetyError) {
621
717
  return denied(`run_shell denied. ${shellSafetyError}`);
622
718
  }
@@ -682,6 +778,42 @@ export class WorkspaceTools {
682
778
  await assertInsideWorkspace(await this.rootRealPath, resolvedPath, requestedPath);
683
779
  return resolvedPath;
684
780
  }
781
+ async resolveDocumentPath(requestedPath) {
782
+ const trimmedPath = requestedPath.trim();
783
+ if (!path.isAbsolute(trimmedPath)) {
784
+ return {
785
+ absolutePath: await this.resolveReadPath(trimmedPath),
786
+ external: false
787
+ };
788
+ }
789
+ if (isSensitivePath(trimmedPath)) {
790
+ throw new Error(`inspect_document denied sensitive path: ${requestedPath}`);
791
+ }
792
+ const relativePath = path.relative(this.root, trimmedPath);
793
+ if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
794
+ return {
795
+ absolutePath: await this.resolveReadPath(trimmedPath),
796
+ external: false
797
+ };
798
+ }
799
+ if (!this.allowExternalFileAnalysis) {
800
+ throw new Error(`Path escapes workspace: ${requestedPath}. Enable /experimental file-analysis to inspect external files.`);
801
+ }
802
+ const extension = path.extname(trimmedPath).toLowerCase();
803
+ if (!isLikelyTextFile(trimmedPath) && extension !== ".pdf" && extension !== ".docx" && !isImageFile(trimmedPath)) {
804
+ throw new Error(`external file analysis does not support ${extension || "this file type"} yet.`);
805
+ }
806
+ const resolvedPath = await realpath(trimmedPath).catch((error) => {
807
+ throw new Error(`file not found or unreadable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
808
+ });
809
+ if (isSensitivePath(resolvedPath)) {
810
+ throw new Error(`inspect_document denied sensitive path: ${requestedPath}`);
811
+ }
812
+ return {
813
+ absolutePath: resolvedPath,
814
+ external: true
815
+ };
816
+ }
685
817
  async resolveWritePath(requestedPath) {
686
818
  const absolutePath = this.resolveInsideWorkspace(requestedPath);
687
819
  const rootRealPath = await this.rootRealPath;
@@ -759,7 +891,18 @@ async function searchTextWithRipgrep(workspaceRoot, query, timeoutMs, signal) {
759
891
  "!**/.netrc",
760
892
  "!**/id_rsa",
761
893
  "!**/id_ed25519",
762
- "!**/known_hosts"
894
+ "!**/known_hosts",
895
+ "!**/Cookies",
896
+ "!**/Network/Cookies",
897
+ "!**/Login Data",
898
+ "!**/Web Data",
899
+ "!**/Chrome/**",
900
+ "!**/Chromium/**",
901
+ "!**/Brave*/**",
902
+ "!**/Microsoft Edge/**",
903
+ "!**/Arc/**",
904
+ "!**/Firefox/**",
905
+ "!**/Safari/**"
763
906
  ];
764
907
  return new Promise((resolve) => {
765
908
  const child = spawn("rg", [
@@ -925,6 +1068,15 @@ function readNumber(value, fallback) {
925
1068
  }
926
1069
  return fallback;
927
1070
  }
1071
+ function readStringArray(value) {
1072
+ if (Array.isArray(value)) {
1073
+ return value.filter((item) => typeof item === "string");
1074
+ }
1075
+ if (typeof value === "string") {
1076
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
1077
+ }
1078
+ return [];
1079
+ }
928
1080
  function isPlaceholderPath(value) {
929
1081
  const normalizedValue = value.trim().toLowerCase().replaceAll("\\", "/");
930
1082
  return ["relative/path", "path/to/file", "file/path", "<path>", "<file>", "filename"].includes(normalizedValue);
@@ -943,7 +1095,7 @@ function isSensitivePath(value) {
943
1095
  normalizedPart.endsWith(".pfx") ||
944
1096
  normalizedPart.startsWith("secrets.") ||
945
1097
  normalizedPart.includes("credentials"));
946
- });
1098
+ }) || blockedPathPatterns.some((pattern) => pattern.test(normalizedPath));
947
1099
  }
948
1100
  function denied(message, tool, approval) {
949
1101
  return {
@@ -958,6 +1110,60 @@ function denied(message, tool, approval) {
958
1110
  function isLikelyTextFile(filePath) {
959
1111
  return textFileExtensions.has(path.extname(filePath).toLowerCase());
960
1112
  }
1113
+ function isImageFile(filePath) {
1114
+ return [".png", ".jpg", ".jpeg", ".webp", ".gif"].includes(path.extname(filePath).toLowerCase());
1115
+ }
1116
+ async function inspectImageFile(filePath) {
1117
+ const buffer = await readFile(filePath);
1118
+ const dimensions = readImageDimensions(buffer, path.extname(filePath).toLowerCase());
1119
+ return {
1120
+ ok: true,
1121
+ summary: `inspected image ${path.basename(filePath)}`,
1122
+ content: [
1123
+ `image: ${path.basename(filePath)}`,
1124
+ `type: ${path.extname(filePath).toLowerCase().replace(".", "") || "unknown"}`,
1125
+ `size: ${buffer.length} bytes`,
1126
+ dimensions ? `dimensions: ${dimensions.width}x${dimensions.height}` : "dimensions: unknown"
1127
+ ].join("\n"),
1128
+ tool: "inspect_document",
1129
+ category: toolSpecs.inspect_document.category
1130
+ };
1131
+ }
1132
+ function readImageDimensions(buffer, extension) {
1133
+ if (extension === ".png" && buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
1134
+ return {
1135
+ width: buffer.readUInt32BE(16),
1136
+ height: buffer.readUInt32BE(20)
1137
+ };
1138
+ }
1139
+ if ((extension === ".jpg" || extension === ".jpeg") && buffer.length >= 4) {
1140
+ let offset = 2;
1141
+ while (offset + 9 < buffer.length) {
1142
+ if (buffer[offset] !== 0xff) {
1143
+ return null;
1144
+ }
1145
+ const marker = buffer[offset + 1];
1146
+ const length = buffer.readUInt16BE(offset + 2);
1147
+ if (marker >= 0xc0 && marker <= 0xc3) {
1148
+ return {
1149
+ height: buffer.readUInt16BE(offset + 5),
1150
+ width: buffer.readUInt16BE(offset + 7)
1151
+ };
1152
+ }
1153
+ offset += 2 + length;
1154
+ }
1155
+ }
1156
+ if (extension === ".webp" && buffer.length >= 30 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
1157
+ const chunk = buffer.subarray(12, 16).toString("ascii");
1158
+ if (chunk === "VP8X") {
1159
+ return {
1160
+ width: 1 + buffer.readUIntLE(24, 3),
1161
+ height: 1 + buffer.readUIntLE(27, 3)
1162
+ };
1163
+ }
1164
+ }
1165
+ return null;
1166
+ }
961
1167
  async function extractPdfText(filePath, timeoutMs, signal) {
962
1168
  try {
963
1169
  const { stdout } = await execFileAsync("pdftotext", ["-layout", filePath, "-"], {
@@ -1074,42 +1280,108 @@ function previewPatch(patchContent) {
1074
1280
  const removed = patchContent.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
1075
1281
  return `Apply patch to ${fileSummary} (+${added}/-${removed}).`;
1076
1282
  }
1077
- function validateShellCommand(command) {
1283
+ function validateShellCommand(command, workspaceRoot) {
1078
1284
  const trimmedCommand = command.trim();
1079
- if (/[;&|><`$\n\r]/.test(trimmedCommand)) {
1080
- return "shell metacharacters are blocked; run a single simple command.";
1285
+ if (/[;&<>`$\n\r]/.test(trimmedCommand)) {
1286
+ return "dangerous shell metacharacters are blocked; pipes are allowed, but command separators, redirects, expansion, and multiline commands are not.";
1287
+ }
1288
+ if (/(^|\s)\|\|(\s|$)/.test(trimmedCommand)) {
1289
+ return "shell command separators are blocked; use a single pipeline.";
1081
1290
  }
1082
1291
  const tokens = trimmedCommand.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
1083
1292
  if (tokens.length === 0) {
1084
1293
  return "command is empty.";
1085
1294
  }
1295
+ for (const segment of splitPipeline(tokens)) {
1296
+ const segmentError = validateShellSegment(segment);
1297
+ if (segmentError) {
1298
+ return segmentError;
1299
+ }
1300
+ }
1301
+ for (const token of tokens.filter((value) => value !== "|")) {
1302
+ const normalizedToken = stripQuotes(token);
1303
+ if (isSensitivePath(normalizedToken)) {
1304
+ return "sensitive path arguments are blocked.";
1305
+ }
1306
+ if (/(^|[\\/])\.\.([\\/]|$)/.test(normalizedToken)) {
1307
+ return "parent directory traversal is blocked.";
1308
+ }
1309
+ const absolutePath = toAbsoluteShellPath(normalizedToken);
1310
+ if (absolutePath) {
1311
+ const relativePath = path.relative(workspaceRoot, absolutePath);
1312
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
1313
+ return "absolute path arguments outside the workspace are blocked. Use inspect_document with /experimental file-analysis for external files.";
1314
+ }
1315
+ }
1316
+ }
1317
+ return null;
1318
+ }
1319
+ function validateShellSegment(tokens) {
1320
+ if (tokens.length === 0) {
1321
+ return "empty shell pipeline segment.";
1322
+ }
1086
1323
  const executable = stripQuotes(tokens[0] ?? "").toLowerCase();
1087
- const subcommand = stripQuotes(tokens[1] ?? "").toLowerCase();
1324
+ const subcommand = findCommandSubcommand(executable, tokens.slice(1));
1088
1325
  if (["bash", "sh", "zsh", "fish", "pwsh", "powershell", "powershell.exe", "python", "python3", "node", "ruby", "perl"].includes(executable)) {
1089
1326
  return `executable "${executable}" is blocked.`;
1090
1327
  }
1091
1328
  if (["rm", "rmdir", "mv", "cp"].includes(executable) && tokens.some((token) => /^-.*[fRr]/.test(stripQuotes(token)))) {
1092
1329
  return `destructive ${executable} flags are blocked.`;
1093
1330
  }
1094
- if (executable === "git" && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
1331
+ if (executable === "git" && subcommand && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
1095
1332
  return `git ${subcommand} is blocked in the shell tool.`;
1096
1333
  }
1097
- if (executable === "npm" && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
1334
+ if (executable === "npm" && subcommand && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
1098
1335
  return `npm ${subcommand} is blocked in the shell tool.`;
1099
1336
  }
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.";
1337
+ return null;
1338
+ }
1339
+ function splitPipeline(tokens) {
1340
+ const segments = [[]];
1341
+ for (const token of tokens) {
1342
+ if (token === "|") {
1343
+ segments.push([]);
1344
+ continue;
1104
1345
  }
1105
- if (/(^|[\\/])\.\.([\\/]|$)/.test(normalizedToken)) {
1106
- return "parent directory traversal is blocked.";
1346
+ segments.at(-1)?.push(token);
1347
+ }
1348
+ return segments;
1349
+ }
1350
+ function findCommandSubcommand(executable, tokens) {
1351
+ for (let index = 0; index < tokens.length; index += 1) {
1352
+ const token = stripQuotes(tokens[index] ?? "");
1353
+ if (!token || token === "--") {
1354
+ continue;
1355
+ }
1356
+ if (executable === "git" && ["-C", "-c", "--git-dir", "--work-tree"].includes(token)) {
1357
+ index += 1;
1358
+ continue;
1359
+ }
1360
+ if (executable === "npm" && ["--prefix", "--userconfig", "--cache"].includes(token)) {
1361
+ index += 1;
1362
+ continue;
1107
1363
  }
1364
+ if (token.startsWith("-")) {
1365
+ continue;
1366
+ }
1367
+ return token.toLowerCase();
1368
+ }
1369
+ return null;
1370
+ }
1371
+ function toAbsoluteShellPath(value) {
1372
+ if (!value || value === "|" || value.startsWith("-")) {
1373
+ return null;
1374
+ }
1375
+ if (path.isAbsolute(value)) {
1376
+ return path.resolve(value);
1377
+ }
1378
+ if (value === "~" || value.startsWith("~/")) {
1379
+ return path.resolve(process.env.HOME ?? "", value === "~" ? "." : value.slice(2));
1108
1380
  }
1109
1381
  return null;
1110
1382
  }
1111
- function previewPackageScript(name, command) {
1112
- const risk = validateShellCommand(command);
1383
+ function previewPackageScript(name, command, workspaceRoot) {
1384
+ const risk = validateShellCommand(command, workspaceRoot);
1113
1385
  const prefix = risk ? `Risky package script (${risk})` : "Run package script";
1114
1386
  return `${prefix}: npm run ${name} -> ${clip(command, 220)}`;
1115
1387
  }