@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.
- package/.env.example +17 -1
- package/README.md +80 -22
- package/SECURITY.md +10 -2
- package/dist/cli.js +59 -13
- package/dist/cli.js.map +1 -1
- package/dist/core/agent.d.ts +3 -0
- package/dist/core/agent.js +56 -12
- package/dist/core/agent.js.map +1 -1
- package/dist/core/cleanup.d.ts +3 -0
- package/dist/core/cleanup.js +29 -0
- package/dist/core/cleanup.js.map +1 -0
- package/dist/core/doctor.d.ts +4 -1
- package/dist/core/doctor.js +119 -1
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/gemini.js +27 -14
- package/dist/core/gemini.js.map +1 -1
- package/dist/core/geminiWrapper.d.ts +51 -0
- package/dist/core/geminiWrapper.js +718 -0
- package/dist/core/geminiWrapper.js.map +1 -0
- package/dist/core/json.js +65 -1
- package/dist/core/json.js.map +1 -1
- package/dist/core/memory.d.ts +16 -0
- package/dist/core/memory.js +108 -0
- package/dist/core/memory.js.map +1 -0
- package/dist/core/modelClient.js +7 -0
- package/dist/core/modelClient.js.map +1 -1
- package/dist/core/nvidia.js +20 -2
- package/dist/core/nvidia.js.map +1 -1
- package/dist/core/openrouter.d.ts +2 -0
- package/dist/core/openrouter.js +51 -7
- package/dist/core/openrouter.js.map +1 -1
- package/dist/core/projectInit.d.ts +6 -0
- package/dist/core/projectInit.js +44 -0
- package/dist/core/projectInit.js.map +1 -0
- package/dist/core/reasoning.js +3 -0
- package/dist/core/reasoning.js.map +1 -1
- package/dist/core/session.d.ts +1 -0
- package/dist/core/session.js +46 -0
- package/dist/core/session.js.map +1 -1
- package/dist/core/types.d.ts +9 -4
- package/dist/core/workspace.d.ts +8 -0
- package/dist/core/workspace.js +314 -21
- package/dist/core/workspace.js.map +1 -1
- package/dist/tui/App.js +571 -81
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/commands.js +35 -6
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/components/ApprovalPanel.d.ts +6 -0
- package/dist/tui/components/ApprovalPanel.js +16 -0
- package/dist/tui/components/ApprovalPanel.js.map +1 -0
- package/dist/tui/components/CommandSuggestions.js +8 -3
- package/dist/tui/components/CommandSuggestions.js.map +1 -1
- package/dist/tui/components/Composer.d.ts +1 -0
- package/dist/tui/components/Composer.js +1 -1
- package/dist/tui/components/Composer.js.map +1 -1
- package/dist/tui/components/ExperimentalPanel.d.ts +10 -0
- package/dist/tui/components/ExperimentalPanel.js +33 -0
- package/dist/tui/components/ExperimentalPanel.js.map +1 -0
- package/dist/tui/components/Header.js +3 -3
- package/dist/tui/components/Header.js.map +1 -1
- package/dist/tui/components/OnboardingPanel.d.ts +13 -1
- package/dist/tui/components/OnboardingPanel.js +23 -9
- package/dist/tui/components/OnboardingPanel.js.map +1 -1
- package/dist/tui/components/Sidebar.js +32 -26
- package/dist/tui/components/Sidebar.js.map +1 -1
- package/dist/tui/components/Transcript.js +4 -3
- package/dist/tui/components/Transcript.js.map +1 -1
- package/dist/tui/format.js +7 -7
- package/dist/tui/format.js.map +1 -1
- package/dist/tui/modes.d.ts +1 -1
- package/dist/tui/modes.js +8 -2
- package/dist/tui/modes.js.map +1 -1
- package/docs/gemini-wrapper.md +87 -0
- package/docs/releases/v0.1.1-beta.md +18 -0
- package/docs/releases/v0.2.1.md +1 -1
- package/docs/releases/v0.3.1-beta.md +4 -0
- package/docs/releases/v0.4.0.md +27 -0
- package/docs/releases/v1.0.0.md +28 -0
- package/docs/showcase/patchpilot-banner.png +0 -0
- package/docs/showcase/patchpilot-logo.png +0 -0
- package/package.json +5 -2
package/dist/core/workspace.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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) =>
|
|
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 (/[
|
|
1064
|
-
return "shell metacharacters are blocked;
|
|
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 =
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
}
|