@jx-grxf/patchpilot 0.3.1-beta → 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 (81) hide show
  1. package/.env.example +17 -1
  2. package/README.md +80 -22
  3. package/SECURITY.md +10 -2
  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/gemini.js +27 -14
  16. package/dist/core/gemini.js.map +1 -1
  17. package/dist/core/geminiWrapper.d.ts +51 -0
  18. package/dist/core/geminiWrapper.js +718 -0
  19. package/dist/core/geminiWrapper.js.map +1 -0
  20. package/dist/core/json.js +65 -1
  21. package/dist/core/json.js.map +1 -1
  22. package/dist/core/memory.d.ts +16 -0
  23. package/dist/core/memory.js +108 -0
  24. package/dist/core/memory.js.map +1 -0
  25. package/dist/core/modelClient.js +7 -0
  26. package/dist/core/modelClient.js.map +1 -1
  27. package/dist/core/nvidia.js +20 -2
  28. package/dist/core/nvidia.js.map +1 -1
  29. package/dist/core/openrouter.d.ts +2 -0
  30. package/dist/core/openrouter.js +51 -7
  31. package/dist/core/openrouter.js.map +1 -1
  32. package/dist/core/projectInit.d.ts +6 -0
  33. package/dist/core/projectInit.js +44 -0
  34. package/dist/core/projectInit.js.map +1 -0
  35. package/dist/core/reasoning.js +3 -0
  36. package/dist/core/reasoning.js.map +1 -1
  37. package/dist/core/session.d.ts +1 -0
  38. package/dist/core/session.js +46 -0
  39. package/dist/core/session.js.map +1 -1
  40. package/dist/core/types.d.ts +9 -4
  41. package/dist/core/workspace.d.ts +8 -0
  42. package/dist/core/workspace.js +314 -21
  43. package/dist/core/workspace.js.map +1 -1
  44. package/dist/tui/App.js +571 -81
  45. package/dist/tui/App.js.map +1 -1
  46. package/dist/tui/commands.js +35 -6
  47. package/dist/tui/commands.js.map +1 -1
  48. package/dist/tui/components/ApprovalPanel.d.ts +6 -0
  49. package/dist/tui/components/ApprovalPanel.js +16 -0
  50. package/dist/tui/components/ApprovalPanel.js.map +1 -0
  51. package/dist/tui/components/CommandSuggestions.js +8 -3
  52. package/dist/tui/components/CommandSuggestions.js.map +1 -1
  53. package/dist/tui/components/Composer.d.ts +1 -0
  54. package/dist/tui/components/Composer.js +1 -1
  55. package/dist/tui/components/Composer.js.map +1 -1
  56. package/dist/tui/components/ExperimentalPanel.d.ts +10 -0
  57. package/dist/tui/components/ExperimentalPanel.js +33 -0
  58. package/dist/tui/components/ExperimentalPanel.js.map +1 -0
  59. package/dist/tui/components/Header.js +3 -3
  60. package/dist/tui/components/Header.js.map +1 -1
  61. package/dist/tui/components/OnboardingPanel.d.ts +13 -1
  62. package/dist/tui/components/OnboardingPanel.js +23 -9
  63. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  64. package/dist/tui/components/Sidebar.js +32 -26
  65. package/dist/tui/components/Sidebar.js.map +1 -1
  66. package/dist/tui/components/Transcript.js +4 -3
  67. package/dist/tui/components/Transcript.js.map +1 -1
  68. package/dist/tui/format.js +7 -7
  69. package/dist/tui/format.js.map +1 -1
  70. package/dist/tui/modes.d.ts +1 -1
  71. package/dist/tui/modes.js +8 -2
  72. package/dist/tui/modes.js.map +1 -1
  73. package/docs/gemini-wrapper.md +87 -0
  74. package/docs/releases/v0.1.1-beta.md +18 -0
  75. package/docs/releases/v0.2.1.md +1 -1
  76. package/docs/releases/v0.3.1-beta.md +4 -0
  77. package/docs/releases/v0.4.0.md +27 -0
  78. package/docs/releases/v1.0.0.md +28 -0
  79. package/docs/showcase/patchpilot-banner.png +0 -0
  80. package/docs/showcase/patchpilot-logo.png +0 -0
  81. package/package.json +5 -2
@@ -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",
@@ -61,13 +62,23 @@ const blockedPathNames = new Set([
61
62
  ".env.development",
62
63
  ".env.production",
63
64
  ".env.test",
65
+ ".envrc",
64
66
  ".npmrc",
65
67
  ".pypirc",
66
68
  ".netrc",
69
+ "credentials.json",
70
+ "secrets.json",
67
71
  "id_rsa",
68
72
  "id_ed25519",
73
+ "id_ecdsa",
74
+ "id_dsa",
69
75
  "known_hosts"
70
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
+ ];
71
82
  export const toolSpecs = {
72
83
  list_files: {
73
84
  name: "list_files",
@@ -117,6 +128,22 @@ export const toolSpecs = {
117
128
  permission: "none",
118
129
  category: "document"
119
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
+ },
120
147
  git_status: {
121
148
  name: "git_status",
122
149
  description: "Read the current Git branch and dirty state.",
@@ -198,6 +225,8 @@ export class WorkspaceTools {
198
225
  rootRealPath;
199
226
  allowWrite;
200
227
  allowShell;
228
+ allowExternalFileAnalysis;
229
+ memoryEnabled;
201
230
  timeoutMs;
202
231
  signal;
203
232
  approvalHandler;
@@ -207,6 +236,8 @@ export class WorkspaceTools {
207
236
  this.rootRealPath = realpath(this.root).catch(() => this.root);
208
237
  this.allowWrite = options.allowWrite;
209
238
  this.allowShell = options.allowShell;
239
+ this.allowExternalFileAnalysis = Boolean(options.allowExternalFileAnalysis);
240
+ this.memoryEnabled = Boolean(options.memoryEnabled);
210
241
  this.timeoutMs = options.timeoutMs ?? 60_000;
211
242
  this.signal = options.signal;
212
243
  this.approvalHandler = options.approvalHandler;
@@ -226,6 +257,10 @@ export class WorkspaceTools {
226
257
  return await this.searchText(readString(call.arguments.query, ""));
227
258
  case "inspect_document":
228
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));
229
264
  case "git_status":
230
265
  return await this.gitStatus();
231
266
  case "git_diff":
@@ -393,10 +428,18 @@ export class WorkspaceTools {
393
428
  if (isSensitivePath(requestedPath)) {
394
429
  return denied(`inspect_document denied sensitive path: ${requestedPath}`);
395
430
  }
396
- 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
+ }
397
440
  const extension = path.extname(absolutePath).toLowerCase();
398
441
  if (isLikelyTextFile(absolutePath)) {
399
- return await this.readFile(requestedPath);
442
+ return await this.readTextDocument(absolutePath);
400
443
  }
401
444
  if (extension === ".pdf") {
402
445
  return await extractPdfText(absolutePath, this.timeoutMs, this.signal);
@@ -404,8 +447,66 @@ export class WorkspaceTools {
404
447
  if (extension === ".docx") {
405
448
  return await extractDocxText(absolutePath);
406
449
  }
450
+ if (isImageFile(absolutePath)) {
451
+ return await inspectImageFile(absolutePath);
452
+ }
407
453
  return denied(`inspect_document does not support ${extension || "this file type"} yet.`);
408
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
+ }
409
510
  async searchText(query) {
410
511
  if (!query.trim()) {
411
512
  return denied("search_text requires a non-empty query.");
@@ -574,10 +675,12 @@ export class WorkspaceTools {
574
675
  if (!scripts[normalizedScript]) {
575
676
  return denied(`package script not found: ${normalizedScript}`, "run_script");
576
677
  }
678
+ const scriptCommand = scripts[normalizedScript];
577
679
  if (!this.allowShell) {
578
680
  const approval = await this.requestApproval("run_script", "shell", {
579
- script: normalizedScript
580
- }, `Run package script: npm run ${normalizedScript}`);
681
+ script: normalizedScript,
682
+ command: scriptCommand
683
+ }, previewPackageScript(normalizedScript, scriptCommand, this.root));
581
684
  if (approval.decision === "deny") {
582
685
  return denied("run_script denied by permission policy.", "run_script", approval);
583
686
  }
@@ -589,7 +692,7 @@ export class WorkspaceTools {
589
692
  content: clip(output.output, 20_000),
590
693
  tool: "run_script",
591
694
  category: toolSpecs.run_script.category,
592
- preview: `npm run ${normalizedScript}`
695
+ preview: previewPackageScript(normalizedScript, scriptCommand, this.root)
593
696
  };
594
697
  }
595
698
  async runTests() {
@@ -609,7 +712,7 @@ export class WorkspaceTools {
609
712
  if (!command.trim()) {
610
713
  return denied("run_shell requires a command.");
611
714
  }
612
- const shellSafetyError = validateShellCommand(command);
715
+ const shellSafetyError = validateShellCommand(command, this.root);
613
716
  if (shellSafetyError) {
614
717
  return denied(`run_shell denied. ${shellSafetyError}`);
615
718
  }
@@ -675,6 +778,42 @@ export class WorkspaceTools {
675
778
  await assertInsideWorkspace(await this.rootRealPath, resolvedPath, requestedPath);
676
779
  return resolvedPath;
677
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
+ }
678
817
  async resolveWritePath(requestedPath) {
679
818
  const absolutePath = this.resolveInsideWorkspace(requestedPath);
680
819
  const rootRealPath = await this.rootRealPath;
@@ -752,7 +891,18 @@ async function searchTextWithRipgrep(workspaceRoot, query, timeoutMs, signal) {
752
891
  "!**/.netrc",
753
892
  "!**/id_rsa",
754
893
  "!**/id_ed25519",
755
- "!**/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/**"
756
906
  ];
757
907
  return new Promise((resolve) => {
758
908
  const child = spawn("rg", [
@@ -839,7 +989,7 @@ async function findNearestExistingParent(absolutePath) {
839
989
  function runCommand(command, cwd, timeoutMs, signal) {
840
990
  const isWindows = platform() === "win32";
841
991
  const shellExecutable = isWindows ? "powershell.exe" : "bash";
842
- const shellArgs = isWindows ? ["-NoProfile", "-Command", command] : ["-lc", command];
992
+ const shellArgs = isWindows ? ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command] : ["-lc", command];
843
993
  return new Promise((resolve) => {
844
994
  const child = spawn(shellExecutable, shellArgs, {
845
995
  cwd,
@@ -918,6 +1068,15 @@ function readNumber(value, fallback) {
918
1068
  }
919
1069
  return fallback;
920
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
+ }
921
1080
  function isPlaceholderPath(value) {
922
1081
  const normalizedValue = value.trim().toLowerCase().replaceAll("\\", "/");
923
1082
  return ["relative/path", "path/to/file", "file/path", "<path>", "<file>", "filename"].includes(normalizedValue);
@@ -927,7 +1086,16 @@ function isSensitivePath(value) {
927
1086
  return normalizedPath
928
1087
  .split("/")
929
1088
  .filter(Boolean)
930
- .some((part) => blockedPathNames.has(part.toLowerCase()));
1089
+ .some((part) => {
1090
+ const normalizedPart = part.toLowerCase();
1091
+ return (blockedPathNames.has(normalizedPart) ||
1092
+ normalizedPart.endsWith(".pem") ||
1093
+ normalizedPart.endsWith(".key") ||
1094
+ normalizedPart.endsWith(".p12") ||
1095
+ normalizedPart.endsWith(".pfx") ||
1096
+ normalizedPart.startsWith("secrets.") ||
1097
+ normalizedPart.includes("credentials"));
1098
+ }) || blockedPathPatterns.some((pattern) => pattern.test(normalizedPath));
931
1099
  }
932
1100
  function denied(message, tool, approval) {
933
1101
  return {
@@ -942,6 +1110,60 @@ function denied(message, tool, approval) {
942
1110
  function isLikelyTextFile(filePath) {
943
1111
  return textFileExtensions.has(path.extname(filePath).toLowerCase());
944
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
+ }
945
1167
  async function extractPdfText(filePath, timeoutMs, signal) {
946
1168
  try {
947
1169
  const { stdout } = await execFileAsync("pdftotext", ["-layout", filePath, "-"], {
@@ -1058,40 +1280,111 @@ function previewPatch(patchContent) {
1058
1280
  const removed = patchContent.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
1059
1281
  return `Apply patch to ${fileSummary} (+${added}/-${removed}).`;
1060
1282
  }
1061
- function validateShellCommand(command) {
1283
+ function validateShellCommand(command, workspaceRoot) {
1062
1284
  const trimmedCommand = command.trim();
1063
- if (/[;&|><`$\n\r]/.test(trimmedCommand)) {
1064
- 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.";
1065
1290
  }
1066
1291
  const tokens = trimmedCommand.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
1067
1292
  if (tokens.length === 0) {
1068
1293
  return "command is empty.";
1069
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
+ }
1070
1323
  const executable = stripQuotes(tokens[0] ?? "").toLowerCase();
1071
- const subcommand = stripQuotes(tokens[1] ?? "").toLowerCase();
1324
+ const subcommand = findCommandSubcommand(executable, tokens.slice(1));
1072
1325
  if (["bash", "sh", "zsh", "fish", "pwsh", "powershell", "powershell.exe", "python", "python3", "node", "ruby", "perl"].includes(executable)) {
1073
1326
  return `executable "${executable}" is blocked.`;
1074
1327
  }
1075
1328
  if (["rm", "rmdir", "mv", "cp"].includes(executable) && tokens.some((token) => /^-.*[fRr]/.test(stripQuotes(token)))) {
1076
1329
  return `destructive ${executable} flags are blocked.`;
1077
1330
  }
1078
- 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)) {
1079
1332
  return `git ${subcommand} is blocked in the shell tool.`;
1080
1333
  }
1081
- if (executable === "npm" && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
1334
+ if (executable === "npm" && subcommand && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
1082
1335
  return `npm ${subcommand} is blocked in the shell tool.`;
1083
1336
  }
1084
- for (const token of tokens.slice(1)) {
1085
- const normalizedToken = stripQuotes(token);
1086
- if (normalizedToken.startsWith("/") || normalizedToken.startsWith("~")) {
1087
- 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;
1088
1345
  }
1089
- if (/(^|[\\/])\.\.([\\/]|$)/.test(normalizedToken)) {
1090
- 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;
1363
+ }
1364
+ if (token.startsWith("-")) {
1365
+ continue;
1091
1366
  }
1367
+ return token.toLowerCase();
1092
1368
  }
1093
1369
  return null;
1094
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));
1380
+ }
1381
+ return null;
1382
+ }
1383
+ function previewPackageScript(name, command, workspaceRoot) {
1384
+ const risk = validateShellCommand(command, workspaceRoot);
1385
+ const prefix = risk ? `Risky package script (${risk})` : "Run package script";
1386
+ return `${prefix}: npm run ${name} -> ${clip(command, 220)}`;
1387
+ }
1095
1388
  function stripQuotes(value) {
1096
1389
  return value.replace(/^['"]|['"]$/g, "");
1097
1390
  }