@diegopetrucci/pi-extensions 0.1.73 → 0.1.76
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/LICENSE +2 -0
- package/README.md +9 -4
- package/extensions/agent-workflow-audit/index.ts +1398 -1009
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +3 -1
- package/extensions/annotate-git-diff/clipboard.ts +109 -100
- package/extensions/annotate-git-diff/git.ts +853 -767
- package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
- package/extensions/annotate-git-diff/index.ts +712 -671
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-git-diff/prompt.ts +45 -45
- package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
- package/extensions/annotate-git-diff/review-server.ts +337 -311
- package/extensions/annotate-git-diff/types.ts +132 -132
- package/extensions/annotate-git-diff/ui.ts +27 -27
- package/extensions/annotate-git-diff/watch.ts +74 -74
- package/extensions/annotate-git-diff/web/app.js +1947 -1884
- package/extensions/annotate-git-diff/web/index.html +5 -6
- package/extensions/annotate-last-message/README.md +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
- package/extensions/annotate-last-message/index.ts +210 -206
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/prompt.ts +87 -66
- package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
- package/extensions/annotate-last-message/session.ts +94 -89
- package/extensions/annotate-last-message/theme.ts +199 -193
- package/extensions/annotate-last-message/types.ts +23 -23
- package/extensions/annotate-last-message/ui.ts +27 -25
- package/extensions/annotate-last-message/web/app.js +4 -7
- package/extensions/annotate-last-message/web/md-renderer.js +1 -1
- package/extensions/brrr/README.md +1 -1
- package/extensions/brrr/index.ts +213 -191
- package/extensions/brrr/package.json +1 -1
- package/extensions/claude-fast/README.md +5 -79
- package/extensions/claude-fast/index.ts +31 -289
- package/extensions/claude-fast/package.json +2 -3
- package/extensions/code-reviewer/README.md +1 -1
- package/extensions/code-reviewer/index.ts +1598 -1350
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/README.md +1 -1
- package/extensions/confirm-destructive/index.ts +55 -51
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/index.ts +312 -304
- package/extensions/context-cap/package.json +1 -1
- package/extensions/context-inspector/index.ts +1211 -1064
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/index.ts +1854 -1735
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +41 -41
- package/extensions/dynamic-context-pruning/README.md +6 -38
- package/extensions/dynamic-context-pruning/index.ts +2124 -1980
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +71 -21
- package/extensions/fast/index.ts +513 -313
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -1
- package/extensions/git-footer/index.ts +458 -411
- package/extensions/git-footer/package.json +2 -3
- package/extensions/gnosis/index.ts +180 -130
- package/extensions/gnosis/package.json +1 -1
- package/extensions/inline-bash/index.ts +88 -88
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/index.ts +1669 -1419
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/index.ts +994 -1012
- package/extensions/minimal-footer/openai-usage.ts +195 -190
- package/extensions/notify/index.ts +222 -230
- package/extensions/notify/package.json +1 -1
- package/extensions/openai-fast/README.md +5 -73
- package/extensions/openai-fast/index.ts +32 -272
- package/extensions/openai-fast/package.json +2 -3
- package/extensions/oracle/index.ts +1731 -1625
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/index.ts +658 -608
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/permission-gate/powershell-safety.ts +840 -793
- package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
- package/extensions/project-mcp-json/README.md +199 -0
- package/extensions/project-mcp-json/config.ts +564 -0
- package/extensions/project-mcp-json/index.ts +119 -0
- package/extensions/project-mcp-json/package.json +38 -0
- package/extensions/quiet-tools/index.ts +282 -264
- package/extensions/review/README.md +2 -2
- package/extensions/review/index.ts +1745 -1872
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +284 -265
- package/extensions/triage-comments/README.md +2 -2
- package/extensions/triage-comments/index.ts +2294 -1882
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +6 -3
- package/assets/social-preview.png +0 -0
- package/extensions/claude-fast/claude-fast.example.json +0 -4
- package/extensions/openai-fast/openai-fast.example.json +0 -4
|
@@ -12,10 +12,10 @@ import * as path from "node:path";
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
analyzePowerShellLexically,
|
|
16
|
+
buildPowerShellAstCommand,
|
|
17
|
+
MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES,
|
|
18
|
+
parsePowerShellAstOutput,
|
|
19
19
|
} from "./powershell-safety.ts";
|
|
20
20
|
|
|
21
21
|
const dangerousPatterns = [/\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
|
@@ -27,25 +27,25 @@ const POWERSHELL_ANALYSIS_TIMEOUT_MS = 5_000;
|
|
|
27
27
|
|
|
28
28
|
type GuardDecision = { block: true; reason: string } | undefined;
|
|
29
29
|
type ConfirmationContext = {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
hasUI?: boolean;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
ui?: {
|
|
33
|
+
select(
|
|
34
|
+
prompt: string,
|
|
35
|
+
choices: string[],
|
|
36
|
+
dialogOptions?: { signal?: AbortSignal },
|
|
37
|
+
): Promise<string | undefined>;
|
|
38
|
+
};
|
|
39
39
|
};
|
|
40
40
|
type NormalizedPath = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
original: string;
|
|
42
|
+
displayPath: string;
|
|
43
|
+
segments: string[];
|
|
44
|
+
basename: string;
|
|
45
45
|
};
|
|
46
46
|
type ShellToken = {
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
text: string;
|
|
48
|
+
quote?: '"' | "'";
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
type WriteInput = { path: string; content: string };
|
|
@@ -53,660 +53,710 @@ type EditInput = { path: string; edits: Array<{ oldText: string; newText: string
|
|
|
53
53
|
type ShellInput = { command: string; timeout?: number };
|
|
54
54
|
|
|
55
55
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
-
|
|
56
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function normalizeToolPath(rawPath: string): NormalizedPath | undefined {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
60
|
+
const trimmed = rawPath.trim();
|
|
61
|
+
if (!trimmed) return undefined;
|
|
62
|
+
|
|
63
|
+
// Pi's built-in path tools treat a leading @ as path syntax and strip it
|
|
64
|
+
// before execution, so inspect the same effective target.
|
|
65
|
+
const withoutPathSigil = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
66
|
+
if (!withoutPathSigil) return undefined;
|
|
67
|
+
|
|
68
|
+
const withPosixSeparators = withoutPathSigil.replace(/\\+/g, "/");
|
|
69
|
+
const normalizedPath = path.posix.normalize(
|
|
70
|
+
withPosixSeparators.startsWith("/")
|
|
71
|
+
? withPosixSeparators
|
|
72
|
+
: path.posix.join("/", withPosixSeparators),
|
|
73
|
+
);
|
|
74
|
+
const segments = normalizedPath.split("/").filter(Boolean);
|
|
75
|
+
const basename = segments.at(-1) ?? "";
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
original: rawPath,
|
|
79
|
+
displayPath: withPosixSeparators,
|
|
80
|
+
segments,
|
|
81
|
+
basename,
|
|
82
|
+
};
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
function isSafeEnvTemplate(basename: string): boolean {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const normalizedBasename = basename.toLowerCase();
|
|
87
|
+
if (!normalizedBasename.startsWith(".env.")) return false;
|
|
88
|
+
const terminalSuffix = normalizedBasename.split(".").at(-1) ?? "";
|
|
89
|
+
return safeEnvSuffixes.has(terminalSuffix);
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
function isProtectedEnvFile(basename: string): boolean {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const normalizedBasename = basename.toLowerCase();
|
|
94
|
+
if (normalizedBasename === ".env") return true;
|
|
95
|
+
if (!normalizedBasename.startsWith(".env.")) return false;
|
|
96
|
+
return !isSafeEnvTemplate(normalizedBasename);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
function isProtectedPath(normalizedPath: NormalizedPath): boolean {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
if (normalizedPath.segments.some((segment) => protectedPathSegments.has(segment.toLowerCase())))
|
|
101
|
+
return true;
|
|
102
|
+
return isProtectedEnvFile(normalizedPath.basename);
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
function findClosingDelimiter(command: string, start: number, delimiter:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
105
|
+
function findClosingDelimiter(command: string, start: number, delimiter: ")" | "`"): number {
|
|
106
|
+
let quote: '"' | "'" | undefined;
|
|
107
|
+
let escaping = false;
|
|
108
|
+
let depth = delimiter === ")" ? 1 : 0;
|
|
109
|
+
|
|
110
|
+
for (let index = start; index < command.length; index += 1) {
|
|
111
|
+
const char = command[index];
|
|
112
|
+
const next = command[index + 1];
|
|
113
|
+
|
|
114
|
+
if (escaping) {
|
|
115
|
+
escaping = false;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (quote) {
|
|
120
|
+
if (char === "\\" && quote === '"') {
|
|
121
|
+
escaping = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (char === quote) {
|
|
125
|
+
quote = undefined;
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (char === "\\") {
|
|
131
|
+
escaping = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (char === '"' || char === "'") {
|
|
136
|
+
quote = char;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (delimiter === ")" && char === "$" && next === "(") {
|
|
141
|
+
depth += 1;
|
|
142
|
+
index += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (char === delimiter) {
|
|
147
|
+
if (delimiter === ")") {
|
|
148
|
+
depth -= 1;
|
|
149
|
+
if (depth === 0) return index;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
return index;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return -1;
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
function tokenizeShellWords(command: string): ShellToken[] {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
160
|
+
// Intentionally shallow tokenization for reviewable safety checks. This handles
|
|
161
|
+
// simple quoting, command separators, and nested command substitutions, but it does
|
|
162
|
+
// not attempt full shell grammar such as heredocs, arrays, or parameter expansion.
|
|
163
|
+
const tokens: ShellToken[] = [];
|
|
164
|
+
let current = "";
|
|
165
|
+
let currentQuote: '"' | "'" | undefined;
|
|
166
|
+
let escaping = false;
|
|
167
|
+
|
|
168
|
+
const flushCurrent = () => {
|
|
169
|
+
if (!current) return;
|
|
170
|
+
tokens.push({ text: current, quote: currentQuote });
|
|
171
|
+
current = "";
|
|
172
|
+
currentQuote = undefined;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
176
|
+
const char = command[index];
|
|
177
|
+
const next = command[index + 1];
|
|
178
|
+
|
|
179
|
+
if (escaping) {
|
|
180
|
+
current += char;
|
|
181
|
+
escaping = false;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (currentQuote) {
|
|
186
|
+
if (char === "\\" && currentQuote === '"') {
|
|
187
|
+
escaping = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (char === currentQuote) {
|
|
191
|
+
flushCurrent();
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
current += char;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (char === "\\") {
|
|
199
|
+
escaping = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (char === '"' || char === "'") {
|
|
204
|
+
flushCurrent();
|
|
205
|
+
currentQuote = char;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (char === "$" && next === "(") {
|
|
210
|
+
const closingIndex = findClosingDelimiter(command, index + 2, ")");
|
|
211
|
+
if (closingIndex !== -1) {
|
|
212
|
+
flushCurrent();
|
|
213
|
+
tokens.push({ text: command.slice(index, closingIndex + 1) });
|
|
214
|
+
index = closingIndex;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (char === "`") {
|
|
220
|
+
const closingIndex = findClosingDelimiter(command, index + 1, "`");
|
|
221
|
+
if (closingIndex !== -1) {
|
|
222
|
+
flushCurrent();
|
|
223
|
+
tokens.push({ text: command.slice(index, closingIndex + 1) });
|
|
224
|
+
index = closingIndex;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (char === "&" && next === "&") {
|
|
230
|
+
flushCurrent();
|
|
231
|
+
tokens.push({ text: "&&" });
|
|
232
|
+
index += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (char === "|" && next === "|") {
|
|
237
|
+
flushCurrent();
|
|
238
|
+
tokens.push({ text: "||" });
|
|
239
|
+
index += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
char === ";" ||
|
|
245
|
+
char === "&" ||
|
|
246
|
+
char === "|" ||
|
|
247
|
+
char === "\n" ||
|
|
248
|
+
char === "(" ||
|
|
249
|
+
char === ")" ||
|
|
250
|
+
char === "{" ||
|
|
251
|
+
char === "}"
|
|
252
|
+
) {
|
|
253
|
+
flushCurrent();
|
|
254
|
+
tokens.push({ text: char });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (/\s/.test(char)) {
|
|
259
|
+
flushCurrent();
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
current += char;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
flushCurrent();
|
|
267
|
+
return tokens;
|
|
256
268
|
}
|
|
257
269
|
|
|
258
270
|
function isShellAssignmentToken(token: string): boolean {
|
|
259
|
-
|
|
271
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
function hasDangerousSubstitution(command: string): boolean {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
275
|
+
let quote: '"' | "'" | undefined;
|
|
276
|
+
let escaping = false;
|
|
277
|
+
|
|
278
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
279
|
+
const char = command[index];
|
|
280
|
+
const next = command[index + 1];
|
|
281
|
+
|
|
282
|
+
if (escaping) {
|
|
283
|
+
escaping = false;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (quote === "'") {
|
|
288
|
+
if (char === "'") quote = undefined;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (char === "\\") {
|
|
293
|
+
escaping = true;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (char === "'") {
|
|
298
|
+
quote = "'";
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (char === '"') {
|
|
303
|
+
quote = quote === '"' ? undefined : '"';
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (char === "$" && next === "(") {
|
|
308
|
+
const closingIndex = findClosingDelimiter(command, index + 2, ")");
|
|
309
|
+
if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 2, closingIndex)))
|
|
310
|
+
return true;
|
|
311
|
+
if (closingIndex !== -1) index = closingIndex;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (char === "`") {
|
|
316
|
+
const closingIndex = findClosingDelimiter(command, index + 1, "`");
|
|
317
|
+
if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 1, closingIndex)))
|
|
318
|
+
return true;
|
|
319
|
+
if (closingIndex !== -1) index = closingIndex;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return false;
|
|
310
324
|
}
|
|
311
325
|
|
|
312
326
|
function basenameToken(token: string): string {
|
|
313
|
-
|
|
327
|
+
return path.posix.basename(token);
|
|
314
328
|
}
|
|
315
329
|
|
|
316
330
|
function unwrapLeadingWrappers(
|
|
317
|
-
|
|
318
|
-
|
|
331
|
+
tokens: ShellToken[],
|
|
332
|
+
startIndex: number,
|
|
319
333
|
): { headIndex: number; nonExecuting: boolean; consumed: number } {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
334
|
+
let index = startIndex;
|
|
335
|
+
|
|
336
|
+
const consumeOptionValue = (
|
|
337
|
+
inlinePrefix: string,
|
|
338
|
+
separateOptions: string[],
|
|
339
|
+
longOption: string,
|
|
340
|
+
): boolean => {
|
|
341
|
+
const token = tokens[index]?.text;
|
|
342
|
+
if (!token) return false;
|
|
343
|
+
if (token === longOption || separateOptions.includes(token)) {
|
|
344
|
+
index += 1;
|
|
345
|
+
if (index < tokens.length) index += 1;
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
if (
|
|
349
|
+
token.startsWith(`${longOption}=`) ||
|
|
350
|
+
(inlinePrefix && token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)
|
|
351
|
+
) {
|
|
352
|
+
index += 1;
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const consumeOptionalLongOption = (
|
|
359
|
+
inlinePrefix: string,
|
|
360
|
+
shortOption: string,
|
|
361
|
+
longOption: string,
|
|
362
|
+
): boolean => {
|
|
363
|
+
const token = tokens[index]?.text;
|
|
364
|
+
if (!token) return false;
|
|
365
|
+
if (token === longOption) {
|
|
366
|
+
index += 1;
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
if (token === shortOption) {
|
|
370
|
+
index += 1;
|
|
371
|
+
if (index < tokens.length) index += 1;
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
if (
|
|
375
|
+
token.startsWith(`${longOption}=`) ||
|
|
376
|
+
(token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)
|
|
377
|
+
) {
|
|
378
|
+
index += 1;
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
while (index < tokens.length && isShellAssignmentToken(tokens[index].text)) index += 1;
|
|
385
|
+
while (tokens[index]?.text === "sudo") index += 1;
|
|
386
|
+
|
|
387
|
+
while (index < tokens.length) {
|
|
388
|
+
const current = tokens[index]?.text;
|
|
389
|
+
const currentBase = current ? basenameToken(current) : undefined;
|
|
390
|
+
if (!current) break;
|
|
391
|
+
|
|
392
|
+
if (current === "command") {
|
|
393
|
+
index += 1;
|
|
394
|
+
while (index < tokens.length) {
|
|
395
|
+
const option = tokens[index]?.text;
|
|
396
|
+
if (!option?.startsWith("-")) break;
|
|
397
|
+
if (option === "--") {
|
|
398
|
+
index += 1;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
if (option === "-v" || option === "-V") {
|
|
402
|
+
return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
|
|
403
|
+
}
|
|
404
|
+
index += 1;
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (currentBase === "env") {
|
|
410
|
+
index += 1;
|
|
411
|
+
while (index < tokens.length) {
|
|
412
|
+
const option = tokens[index]?.text;
|
|
413
|
+
if (!option) break;
|
|
414
|
+
if (option === "--") {
|
|
415
|
+
index += 1;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
if (option === "--help" || option === "--version") {
|
|
419
|
+
return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
|
|
420
|
+
}
|
|
421
|
+
if (isShellAssignmentToken(option)) {
|
|
422
|
+
index += 1;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (consumeOptionValue("-u", ["-u"], "--unset")) continue;
|
|
426
|
+
if (consumeOptionValue("-C", ["-C"], "--chdir")) continue;
|
|
427
|
+
if (!option.startsWith("-")) break;
|
|
428
|
+
index += 1;
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (currentBase === "xargs") {
|
|
434
|
+
index += 1;
|
|
435
|
+
while (index < tokens.length) {
|
|
436
|
+
const option = tokens[index]?.text;
|
|
437
|
+
if (!option) break;
|
|
438
|
+
if (option === "--") {
|
|
439
|
+
index += 1;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
if (option === "--help" || option === "--version") {
|
|
443
|
+
return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
|
|
444
|
+
}
|
|
445
|
+
if (consumeOptionValue("-n", ["-n"], "--max-args")) continue;
|
|
446
|
+
if (consumeOptionValue("-P", ["-P"], "--max-procs")) continue;
|
|
447
|
+
if (consumeOptionalLongOption("-I", "-I", "--replace")) continue;
|
|
448
|
+
if (consumeOptionValue("-a", ["-a"], "--arg-file")) continue;
|
|
449
|
+
if (consumeOptionalLongOption("-E", "-E", "--eof")) continue;
|
|
450
|
+
if (consumeOptionValue("-s", ["-s"], "--max-chars")) continue;
|
|
451
|
+
if (consumeOptionValue("-d", ["-d"], "--delimiter")) continue;
|
|
452
|
+
if (consumeOptionalLongOption("-L", "-L", "--max-lines")) continue;
|
|
453
|
+
if (!option.startsWith("-")) break;
|
|
454
|
+
index += 1;
|
|
455
|
+
}
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { headIndex: index, nonExecuting: false, consumed: index - startIndex };
|
|
435
463
|
}
|
|
436
464
|
|
|
437
465
|
function detectRecursiveForceRm(tokens: ShellToken[], headIndex: number): boolean {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
466
|
+
const commandName = tokens[headIndex];
|
|
467
|
+
if (!commandName || basenameToken(commandName.text) !== "rm") return false;
|
|
468
|
+
|
|
469
|
+
let sawRecursive = false;
|
|
470
|
+
let sawForce = false;
|
|
471
|
+
|
|
472
|
+
for (let index = headIndex + 1; index < tokens.length; index += 1) {
|
|
473
|
+
const token = tokens[index]?.text;
|
|
474
|
+
if (!token) continue;
|
|
475
|
+
if (token === "--") break;
|
|
476
|
+
if (!token.startsWith("-") || token === "-") continue;
|
|
477
|
+
if (token === "--recursive") sawRecursive = true;
|
|
478
|
+
if (token === "--force") sawForce = true;
|
|
479
|
+
if (/^-[^-]+$/.test(token)) {
|
|
480
|
+
const flags = token.slice(1);
|
|
481
|
+
if (flags.includes("r") || flags.includes("R")) sawRecursive = true;
|
|
482
|
+
if (flags.includes("f")) sawForce = true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return sawRecursive && sawForce;
|
|
459
487
|
}
|
|
460
488
|
|
|
461
489
|
function hasDangerousCommandHead(tokens: ShellToken[], headIndex: number): boolean {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
490
|
+
if (detectRecursiveForceRm(tokens, headIndex)) return true;
|
|
491
|
+
|
|
492
|
+
const firstToken = tokens[headIndex];
|
|
493
|
+
if (!firstToken) return false;
|
|
494
|
+
|
|
495
|
+
if (["sh", "bash", "zsh", "dash", "ksh"].includes(basenameToken(firstToken.text))) {
|
|
496
|
+
for (let index = headIndex + 1; index < tokens.length; index += 1) {
|
|
497
|
+
const token = tokens[index]?.text;
|
|
498
|
+
if (!token) break;
|
|
499
|
+
if (token === "--") break;
|
|
500
|
+
if (token === "-c" || (/^-[A-Za-z]+$/.test(token) && token.includes("c"))) {
|
|
501
|
+
const script = tokens[index + 1]?.text;
|
|
502
|
+
return typeof script === "string" && hasDangerousRecursiveRm(script);
|
|
503
|
+
}
|
|
504
|
+
if (!token.startsWith("-")) break;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (firstToken.text === "eval") {
|
|
509
|
+
return (
|
|
510
|
+
headIndex + 1 < tokens.length &&
|
|
511
|
+
hasDangerousRecursiveRm(
|
|
512
|
+
tokens
|
|
513
|
+
.slice(headIndex + 1)
|
|
514
|
+
.map(({ text }) => text)
|
|
515
|
+
.join(" "),
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return false;
|
|
485
521
|
}
|
|
486
522
|
|
|
487
523
|
function hasDangerousCommandSuffix(tokens: ShellToken[]): boolean {
|
|
488
|
-
|
|
524
|
+
let index = 0;
|
|
489
525
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
526
|
+
while (index < tokens.length) {
|
|
527
|
+
const unwrapped = unwrapLeadingWrappers(tokens, index);
|
|
528
|
+
if (unwrapped.nonExecuting) return false;
|
|
529
|
+
if (hasDangerousCommandHead(tokens, unwrapped.headIndex)) return true;
|
|
494
530
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
531
|
+
// Check every possible executable head once, while skipping known-wrapper
|
|
532
|
+
// option values so data such as an xargs EOF marker is never treated as a command.
|
|
533
|
+
index += unwrapped.consumed + 1;
|
|
534
|
+
}
|
|
499
535
|
|
|
500
|
-
|
|
536
|
+
return false;
|
|
501
537
|
}
|
|
502
538
|
|
|
503
539
|
function hasDangerousRecursiveRm(command: string): boolean {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
540
|
+
// Conservative detector: recurse through obvious command-substitution and shell-wrapper
|
|
541
|
+
// surfaces, then inspect argv-like tokens for rm plus recursive+force semantics.
|
|
542
|
+
if (hasDangerousSubstitution(command)) return true;
|
|
543
|
+
|
|
544
|
+
const tokens = tokenizeShellWords(command);
|
|
545
|
+
let currentCommand: ShellToken[] = [];
|
|
546
|
+
let findExecCommand: ShellToken[] | undefined;
|
|
547
|
+
|
|
548
|
+
const evaluateTokens = (commandTokens: ShellToken[]): boolean =>
|
|
549
|
+
hasDangerousCommandSuffix(commandTokens);
|
|
550
|
+
|
|
551
|
+
const resetCurrentCommand = () => {
|
|
552
|
+
currentCommand = [];
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
for (const token of tokens) {
|
|
556
|
+
if (findExecCommand) {
|
|
557
|
+
if (token.text === ";" || token.text === "+") {
|
|
558
|
+
if (evaluateTokens(findExecCommand)) return true;
|
|
559
|
+
findExecCommand = undefined;
|
|
560
|
+
resetCurrentCommand();
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
findExecCommand.push(token);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (token.text === "-exec" || token.text === "-execdir") {
|
|
568
|
+
findExecCommand = [];
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (shellCommandSeparators.has(token.text)) {
|
|
573
|
+
if (evaluateTokens(currentCommand)) return true;
|
|
574
|
+
resetCurrentCommand();
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
currentCommand.push(token);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (findExecCommand && evaluateTokens(findExecCommand)) return true;
|
|
582
|
+
return evaluateTokens(currentCommand);
|
|
546
583
|
}
|
|
547
584
|
|
|
548
585
|
function validateShellInput(input: unknown): ShellInput | undefined {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
586
|
+
if (!isRecord(input) || typeof input.command !== "string" || input.command.trim() === "")
|
|
587
|
+
return undefined;
|
|
588
|
+
if (
|
|
589
|
+
input.timeout !== undefined &&
|
|
590
|
+
(typeof input.timeout !== "number" ||
|
|
591
|
+
!Number.isFinite(input.timeout) ||
|
|
592
|
+
input.timeout <= 0 ||
|
|
593
|
+
input.timeout > MAX_SHELL_TIMEOUT_SECONDS)
|
|
594
|
+
) {
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
return input.timeout === undefined
|
|
598
|
+
? { command: input.command }
|
|
599
|
+
: { command: input.command, timeout: input.timeout };
|
|
562
600
|
}
|
|
563
601
|
|
|
564
602
|
async function hasDangerousPowerShellCommand(
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
603
|
+
pi: ExtensionAPI,
|
|
604
|
+
command: string,
|
|
605
|
+
signal?: AbortSignal,
|
|
568
606
|
): Promise<boolean> {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
607
|
+
// Keep both lexical work and Windows' base64-expanded -Command transport
|
|
608
|
+
// bounded below the platform command-line limit. Oversized input is
|
|
609
|
+
// deliberately treated as requiring confirmation rather than failing open.
|
|
610
|
+
if (Buffer.byteLength(command, "utf8") > MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES) return true;
|
|
611
|
+
|
|
612
|
+
const lexicalAnalysis = analyzePowerShellLexically(command);
|
|
613
|
+
if (lexicalAnalysis.risky) return true;
|
|
614
|
+
|
|
615
|
+
// Pi only exposes its built-in PowerShell tool on Windows. On that platform,
|
|
616
|
+
// ask PowerShell's own parser for the final word so ordinary syntax features
|
|
617
|
+
// (module qualification, interpolation, splatting, and backtick escapes) cannot
|
|
618
|
+
// bypass the fallback lexer. Parser failures and malformed output fail closed.
|
|
619
|
+
if (process.platform !== "win32") return false;
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const { getPowerShellConfig } = await import("@earendil-works/pi-coding-agent");
|
|
623
|
+
const config = getPowerShellConfig();
|
|
624
|
+
const result = await pi.exec(
|
|
625
|
+
config.shell,
|
|
626
|
+
[...config.args, buildPowerShellAstCommand(command)],
|
|
627
|
+
{
|
|
628
|
+
timeout: POWERSHELL_ANALYSIS_TIMEOUT_MS,
|
|
629
|
+
signal,
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
if (result.code !== 0 || result.killed) return true;
|
|
633
|
+
return parsePowerShellAstOutput(result.stdout).risky;
|
|
634
|
+
} catch {
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
595
637
|
}
|
|
596
638
|
|
|
597
639
|
function validateWriteInput(input: unknown): WriteInput | undefined {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
640
|
+
if (!isRecord(input)) return undefined;
|
|
641
|
+
if (typeof input.path !== "string" || typeof input.content !== "string") return undefined;
|
|
642
|
+
if (!normalizeToolPath(input.path)) return undefined;
|
|
643
|
+
return { path: input.path, content: input.content };
|
|
602
644
|
}
|
|
603
645
|
|
|
604
646
|
function validateEditInput(input: unknown): EditInput | undefined {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
647
|
+
if (!isRecord(input)) return undefined;
|
|
648
|
+
if (typeof input.path !== "string" || !Array.isArray(input.edits)) return undefined;
|
|
649
|
+
if (!normalizeToolPath(input.path)) return undefined;
|
|
650
|
+
|
|
651
|
+
const edits = input.edits;
|
|
652
|
+
if (
|
|
653
|
+
edits.some(
|
|
654
|
+
(edit) =>
|
|
655
|
+
!isRecord(edit) || typeof edit.oldText !== "string" || typeof edit.newText !== "string",
|
|
656
|
+
)
|
|
657
|
+
) {
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
path: input.path,
|
|
663
|
+
edits: edits as Array<{ oldText: string; newText: string }>,
|
|
664
|
+
};
|
|
623
665
|
}
|
|
624
666
|
|
|
625
|
-
async function requestConfirmation(
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
667
|
+
async function requestConfirmation(
|
|
668
|
+
prompt: string,
|
|
669
|
+
ctx: ConfirmationContext,
|
|
670
|
+
): Promise<GuardDecision> {
|
|
671
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
672
|
+
return { block: true, reason: "Confirmation unavailable (no UI)" };
|
|
673
|
+
}
|
|
674
|
+
if (ctx.signal?.aborted) {
|
|
675
|
+
return { block: true, reason: "Confirmation cancelled" };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
const choice = await ctx.ui.select(prompt, ["Yes", "No"], { signal: ctx.signal });
|
|
680
|
+
if (ctx.signal?.aborted) {
|
|
681
|
+
return { block: true, reason: "Confirmation cancelled" };
|
|
682
|
+
}
|
|
683
|
+
return choice === "Yes" ? undefined : { block: true, reason: "Blocked by user" };
|
|
684
|
+
} catch {
|
|
685
|
+
// A dismissed or failed UI must never turn a confirmation failure into an allow.
|
|
686
|
+
return { block: true, reason: "Confirmation unavailable" };
|
|
687
|
+
}
|
|
643
688
|
}
|
|
644
689
|
|
|
645
690
|
async function confirmProtectedPathAction(
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
691
|
+
toolName: "write" | "edit",
|
|
692
|
+
normalizedPath: NormalizedPath,
|
|
693
|
+
ctx: ConfirmationContext,
|
|
649
694
|
): Promise<GuardDecision> {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
695
|
+
if (!isProtectedPath(normalizedPath)) return undefined;
|
|
696
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
697
|
+
return {
|
|
698
|
+
block: true,
|
|
699
|
+
reason: `Protected path blocked (${toolName} without UI confirmation): ${normalizedPath.displayPath}`,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return requestConfirmation(
|
|
704
|
+
`⚠️ Protected path ${toolName} request:\n\n ${normalizedPath.displayPath}\n\nAllow?`,
|
|
705
|
+
ctx,
|
|
706
|
+
);
|
|
659
707
|
}
|
|
660
708
|
|
|
661
709
|
export default function (pi: ExtensionAPI) {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
710
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
711
|
+
if (event.toolName === "bash" || event.toolName === "powershell") {
|
|
712
|
+
const shellToolName = event.toolName;
|
|
713
|
+
const input = validateShellInput(event.input);
|
|
714
|
+
if (!input) {
|
|
715
|
+
return { block: true, reason: `Malformed ${shellToolName} command blocked` };
|
|
716
|
+
}
|
|
717
|
+
const { command } = input;
|
|
718
|
+
|
|
719
|
+
const isDangerous =
|
|
720
|
+
shellToolName === "powershell"
|
|
721
|
+
? await hasDangerousPowerShellCommand(pi, command, ctx.signal)
|
|
722
|
+
: hasDangerousRecursiveRm(command) ||
|
|
723
|
+
dangerousPatterns.some((pattern) => pattern.test(command));
|
|
724
|
+
|
|
725
|
+
if (isDangerous) {
|
|
726
|
+
if (!ctx.hasUI) {
|
|
727
|
+
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return requestConfirmation(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ctx);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (event.toolName === "write") {
|
|
737
|
+
const input = validateWriteInput(event.input);
|
|
738
|
+
if (!input) {
|
|
739
|
+
return { block: true, reason: "Malformed write input blocked" };
|
|
740
|
+
}
|
|
741
|
+
const normalizedPath = normalizeToolPath(input.path);
|
|
742
|
+
if (!normalizedPath) {
|
|
743
|
+
return { block: true, reason: "Malformed write input blocked" };
|
|
744
|
+
}
|
|
745
|
+
return confirmProtectedPathAction("write", normalizedPath, ctx);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (event.toolName === "edit") {
|
|
749
|
+
const input = validateEditInput(event.input);
|
|
750
|
+
if (!input) {
|
|
751
|
+
return { block: true, reason: "Malformed edit input blocked" };
|
|
752
|
+
}
|
|
753
|
+
const normalizedPath = normalizeToolPath(input.path);
|
|
754
|
+
if (!normalizedPath) {
|
|
755
|
+
return { block: true, reason: "Malformed edit input blocked" };
|
|
756
|
+
}
|
|
757
|
+
return confirmProtectedPathAction("edit", normalizedPath, ctx);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return undefined;
|
|
761
|
+
});
|
|
712
762
|
}
|