@diegopetrucci/pi-extensions 0.1.74 → 0.1.78
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 +17 -12
- package/extensions/agent-workflow-audit/README.md +1 -1
- package/extensions/agent-workflow-audit/index.ts +1631 -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 +846 -768
- 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/index.html +3 -3
- 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 +6 -6
- package/extensions/code-reviewer/index.ts +1825 -1373
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/README.md +1 -1
- package/extensions/confirm-destructive/index.ts +50 -52
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/README.md +9 -9
- package/extensions/context-cap/index.ts +313 -305
- package/extensions/context-cap/package.json +2 -2
- package/extensions/context-inspector/index.ts +1211 -1064
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +1863 -1737
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +37 -41
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +11 -43
- package/extensions/dynamic-context-pruning/index.ts +2131 -2105
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +52 -14
- package/extensions/fast/index.ts +513 -317
- 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 +87 -103
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/README.md +1 -1
- package/extensions/librarian/index.ts +1877 -1419
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +987 -1012
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/openai-usage.ts +195 -190
- package/extensions/minimal-footer/package.json +1 -1
- 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/README.md +4 -3
- package/extensions/oracle/index.ts +1740 -1627
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +785 -612
- 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/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/README.md +30 -19
- package/extensions/quiet-tools/index.ts +376 -291
- package/extensions/quiet-tools/package.json +2 -2
- 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 +280 -280
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/README.md +6 -4
- package/extensions/triage-comments/index.ts +2620 -1888
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +9 -6
- 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
|
@@ -7,17 +7,25 @@
|
|
|
7
7
|
* secret-bearing .env files (excluding example/template variants).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import * as os from "node:os";
|
|
10
11
|
import * as path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
11
13
|
|
|
12
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
15
|
|
|
14
16
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
analyzePowerShellLexically,
|
|
18
|
+
buildPowerShellAstCommand,
|
|
19
|
+
MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES,
|
|
20
|
+
parsePowerShellAstOutput,
|
|
19
21
|
} from "./powershell-safety.ts";
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Unicode space characters that Pi's path normalizer replaces with an ASCII space.
|
|
25
|
+
* Mirrors the UNICODE_SPACES constant in dist/utils/paths.js.
|
|
26
|
+
*/
|
|
27
|
+
const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
28
|
+
|
|
21
29
|
const dangerousPatterns = [/\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
|
22
30
|
const protectedPathSegments = new Set([".git", "node_modules"]);
|
|
23
31
|
const shellCommandSeparators = new Set([";", "&", "&&", "||", "|", "\n", "(", ")", "{", "}"]);
|
|
@@ -27,686 +35,851 @@ const POWERSHELL_ANALYSIS_TIMEOUT_MS = 5_000;
|
|
|
27
35
|
|
|
28
36
|
type GuardDecision = { block: true; reason: string } | undefined;
|
|
29
37
|
type ConfirmationContext = {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
hasUI?: boolean;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
ui?: {
|
|
41
|
+
select(
|
|
42
|
+
prompt: string,
|
|
43
|
+
choices: string[],
|
|
44
|
+
dialogOptions?: { signal?: AbortSignal },
|
|
45
|
+
): Promise<string | undefined>;
|
|
46
|
+
};
|
|
39
47
|
};
|
|
40
48
|
type NormalizedPath = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
original: string;
|
|
50
|
+
displayPath: string;
|
|
51
|
+
segments: string[];
|
|
52
|
+
basename: string;
|
|
45
53
|
};
|
|
46
54
|
type ShellToken = {
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
text: string;
|
|
56
|
+
quote?: '"' | "'";
|
|
49
57
|
};
|
|
50
58
|
|
|
51
|
-
type WriteInput = { path: string; content: string };
|
|
52
|
-
type EditInput = { path: string; edits: Array<{ oldText: string; newText: string }> };
|
|
53
59
|
type ShellInput = { command: string; timeout?: number };
|
|
54
60
|
|
|
55
61
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
-
|
|
62
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Converts Git Bash / MSYS / Cygwin / WSL drive paths to a form native Windows
|
|
67
|
+
* APIs accept. Mirrors normalizeWindowsShellPath from
|
|
68
|
+
* dist/utils/paths.js. No-op on non-win32 paths.
|
|
69
|
+
*/
|
|
70
|
+
function normalizeWindowsShellPathLocal(filePath: string): string {
|
|
71
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
|
|
72
|
+
return filePath;
|
|
73
|
+
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
74
|
+
if (!match) return filePath;
|
|
75
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
76
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mirrors Pi's normalizePath (dist/utils/paths.js).
|
|
81
|
+
*
|
|
82
|
+
* Options (all default false/true as noted):
|
|
83
|
+
* normalizeUnicodeSpaces — replace Unicode space characters with ASCII space.
|
|
84
|
+
* stripAtPrefix — strip a leading "@" path sigil.
|
|
85
|
+
* expandTilde — expand "~"/"~/" to homedir (default true).
|
|
86
|
+
*
|
|
87
|
+
* On win32, Git Bash / WSL drive-path normalisation always runs (not gated
|
|
88
|
+
* by expandTilde); only the tilde expansion itself is gated by expandTilde.
|
|
89
|
+
* Always resolves "file://" URLs via fileURLToPath; throws for non-local hosts
|
|
90
|
+
* (e.g. file://hostname/…) or invalid URLs.
|
|
91
|
+
*/
|
|
92
|
+
function normalizePath(
|
|
93
|
+
input: string,
|
|
94
|
+
opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
|
|
95
|
+
): string {
|
|
96
|
+
const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
|
|
97
|
+
let p = input;
|
|
98
|
+
if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
|
|
99
|
+
if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
|
|
100
|
+
if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
|
|
101
|
+
if (expandTilde) {
|
|
102
|
+
const home = os.homedir();
|
|
103
|
+
if (p === "~") p = home;
|
|
104
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
|
|
105
|
+
p = path.join(home, p.slice(2));
|
|
106
|
+
}
|
|
107
|
+
if (/^file:\/\//.test(p)) p = fileURLToPath(p);
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js): normalises the
|
|
113
|
+
* raw path with { normalizeUnicodeSpaces: true, stripAtPrefix: true }, normalises
|
|
114
|
+
* cwd with default options (expandTilde: true, so win32 shell-path handling
|
|
115
|
+
* also applies to cwd as Pi does), then resolves absolute paths via
|
|
116
|
+
* path.resolve and relative paths against cwd.
|
|
117
|
+
*
|
|
118
|
+
* Exported for tests that check parity with Pi's resolveToCwd. Note: other
|
|
119
|
+
* extensions will not import this file; each extension keeps its own local copy.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveToCwd(rawPath: string, cwd: string): string {
|
|
122
|
+
const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
|
|
123
|
+
const base = normalizePath(cwd);
|
|
124
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type PiCheckResult =
|
|
128
|
+
| { status: "protected"; resolvedPath: string }
|
|
129
|
+
| { status: "error" }
|
|
130
|
+
| { status: "allowed" };
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolves rawPath via the Pi-mirroring resolveToCwd, then checks whether the
|
|
134
|
+
* resulting absolute path is a protected path.
|
|
135
|
+
*/
|
|
136
|
+
function checkPiResolvedPath(rawPath: string, cwd: string): PiCheckResult {
|
|
137
|
+
try {
|
|
138
|
+
const resolved = resolveToCwd(rawPath, cwd);
|
|
139
|
+
const normalized = normalizeToolPath(resolved);
|
|
140
|
+
if (normalized !== undefined && isProtectedPath(normalized)) {
|
|
141
|
+
return { status: "protected", resolvedPath: resolved };
|
|
142
|
+
}
|
|
143
|
+
return { status: "allowed" };
|
|
144
|
+
} catch {
|
|
145
|
+
return { status: "error" };
|
|
146
|
+
}
|
|
57
147
|
}
|
|
58
148
|
|
|
59
149
|
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
|
-
|
|
150
|
+
const trimmed = rawPath.trim();
|
|
151
|
+
if (!trimmed) return undefined;
|
|
152
|
+
|
|
153
|
+
// Pi's built-in path tools treat a leading @ as path syntax and strip it
|
|
154
|
+
// before execution, so inspect the same effective target.
|
|
155
|
+
const withoutPathSigil = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
156
|
+
if (!withoutPathSigil) return undefined;
|
|
157
|
+
|
|
158
|
+
const withPosixSeparators = withoutPathSigil.replace(/\\+/g, "/");
|
|
159
|
+
const normalizedPath = path.posix.normalize(
|
|
160
|
+
withPosixSeparators.startsWith("/")
|
|
161
|
+
? withPosixSeparators
|
|
162
|
+
: path.posix.join("/", withPosixSeparators),
|
|
163
|
+
);
|
|
164
|
+
const segments = normalizedPath.split("/").filter(Boolean);
|
|
165
|
+
const basename = segments.at(-1) ?? "";
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
original: rawPath,
|
|
169
|
+
displayPath: withPosixSeparators,
|
|
170
|
+
segments,
|
|
171
|
+
basename,
|
|
172
|
+
};
|
|
81
173
|
}
|
|
82
174
|
|
|
83
175
|
function isSafeEnvTemplate(basename: string): boolean {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
176
|
+
const normalizedBasename = basename.toLowerCase();
|
|
177
|
+
if (!normalizedBasename.startsWith(".env.")) return false;
|
|
178
|
+
const terminalSuffix = normalizedBasename.split(".").at(-1) ?? "";
|
|
179
|
+
return safeEnvSuffixes.has(terminalSuffix);
|
|
88
180
|
}
|
|
89
181
|
|
|
90
182
|
function isProtectedEnvFile(basename: string): boolean {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
183
|
+
const normalizedBasename = basename.toLowerCase();
|
|
184
|
+
if (normalizedBasename === ".env") return true;
|
|
185
|
+
if (!normalizedBasename.startsWith(".env.")) return false;
|
|
186
|
+
return !isSafeEnvTemplate(normalizedBasename);
|
|
95
187
|
}
|
|
96
188
|
|
|
97
189
|
function isProtectedPath(normalizedPath: NormalizedPath): boolean {
|
|
98
|
-
|
|
99
|
-
|
|
190
|
+
if (normalizedPath.segments.some((segment) => protectedPathSegments.has(segment.toLowerCase())))
|
|
191
|
+
return true;
|
|
192
|
+
return isProtectedEnvFile(normalizedPath.basename);
|
|
100
193
|
}
|
|
101
194
|
|
|
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
|
-
|
|
195
|
+
function findClosingDelimiter(command: string, start: number, delimiter: ")" | "`"): number {
|
|
196
|
+
let quote: '"' | "'" | undefined;
|
|
197
|
+
let escaping = false;
|
|
198
|
+
let depth = delimiter === ")" ? 1 : 0;
|
|
199
|
+
|
|
200
|
+
for (let index = start; index < command.length; index += 1) {
|
|
201
|
+
const char = command[index];
|
|
202
|
+
const next = command[index + 1];
|
|
203
|
+
|
|
204
|
+
if (escaping) {
|
|
205
|
+
escaping = false;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (quote) {
|
|
210
|
+
if (char === "\\" && quote === '"') {
|
|
211
|
+
escaping = true;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (char === quote) {
|
|
215
|
+
quote = undefined;
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (char === "\\") {
|
|
221
|
+
escaping = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (char === '"' || char === "'") {
|
|
226
|
+
quote = char;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (delimiter === ")" && char === "$" && next === "(") {
|
|
231
|
+
depth += 1;
|
|
232
|
+
index += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (char === delimiter) {
|
|
237
|
+
if (delimiter === ")") {
|
|
238
|
+
depth -= 1;
|
|
239
|
+
if (depth === 0) return index;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
return index;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return -1;
|
|
154
247
|
}
|
|
155
248
|
|
|
156
249
|
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
|
-
|
|
250
|
+
// Intentionally shallow tokenization for reviewable safety checks. This handles
|
|
251
|
+
// simple quoting, command separators, and nested command substitutions, but it does
|
|
252
|
+
// not attempt full shell grammar such as heredocs, arrays, or parameter expansion.
|
|
253
|
+
const tokens: ShellToken[] = [];
|
|
254
|
+
let current = "";
|
|
255
|
+
let currentQuote: '"' | "'" | undefined;
|
|
256
|
+
let escaping = false;
|
|
257
|
+
|
|
258
|
+
const flushCurrent = () => {
|
|
259
|
+
if (!current) return;
|
|
260
|
+
tokens.push({ text: current, quote: currentQuote });
|
|
261
|
+
current = "";
|
|
262
|
+
currentQuote = undefined;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
266
|
+
const char = command[index];
|
|
267
|
+
const next = command[index + 1];
|
|
268
|
+
|
|
269
|
+
if (escaping) {
|
|
270
|
+
current += char;
|
|
271
|
+
escaping = false;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (currentQuote) {
|
|
276
|
+
if (char === "\\" && currentQuote === '"') {
|
|
277
|
+
escaping = true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (char === currentQuote) {
|
|
281
|
+
flushCurrent();
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
current += char;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (char === "\\") {
|
|
289
|
+
escaping = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (char === '"' || char === "'") {
|
|
294
|
+
flushCurrent();
|
|
295
|
+
currentQuote = char;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (char === "$" && next === "(") {
|
|
300
|
+
const closingIndex = findClosingDelimiter(command, index + 2, ")");
|
|
301
|
+
if (closingIndex !== -1) {
|
|
302
|
+
flushCurrent();
|
|
303
|
+
tokens.push({ text: command.slice(index, closingIndex + 1) });
|
|
304
|
+
index = closingIndex;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (char === "`") {
|
|
310
|
+
const closingIndex = findClosingDelimiter(command, index + 1, "`");
|
|
311
|
+
if (closingIndex !== -1) {
|
|
312
|
+
flushCurrent();
|
|
313
|
+
tokens.push({ text: command.slice(index, closingIndex + 1) });
|
|
314
|
+
index = closingIndex;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (char === "&" && next === "&") {
|
|
320
|
+
flushCurrent();
|
|
321
|
+
tokens.push({ text: "&&" });
|
|
322
|
+
index += 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (char === "|" && next === "|") {
|
|
327
|
+
flushCurrent();
|
|
328
|
+
tokens.push({ text: "||" });
|
|
329
|
+
index += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (
|
|
334
|
+
char === ";" ||
|
|
335
|
+
char === "&" ||
|
|
336
|
+
char === "|" ||
|
|
337
|
+
char === "\n" ||
|
|
338
|
+
char === "(" ||
|
|
339
|
+
char === ")" ||
|
|
340
|
+
char === "{" ||
|
|
341
|
+
char === "}"
|
|
342
|
+
) {
|
|
343
|
+
flushCurrent();
|
|
344
|
+
tokens.push({ text: char });
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (/\s/.test(char)) {
|
|
349
|
+
flushCurrent();
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
current += char;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
flushCurrent();
|
|
357
|
+
return tokens;
|
|
256
358
|
}
|
|
257
359
|
|
|
258
360
|
function isShellAssignmentToken(token: string): boolean {
|
|
259
|
-
|
|
361
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
|
|
260
362
|
}
|
|
261
363
|
|
|
262
364
|
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
|
-
|
|
365
|
+
let quote: '"' | "'" | undefined;
|
|
366
|
+
let escaping = false;
|
|
367
|
+
|
|
368
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
369
|
+
const char = command[index];
|
|
370
|
+
const next = command[index + 1];
|
|
371
|
+
|
|
372
|
+
if (escaping) {
|
|
373
|
+
escaping = false;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (quote === "'") {
|
|
378
|
+
if (char === "'") quote = undefined;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (char === "\\") {
|
|
383
|
+
escaping = true;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (char === "'") {
|
|
388
|
+
quote = "'";
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (char === '"') {
|
|
393
|
+
quote = quote === '"' ? undefined : '"';
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (char === "$" && next === "(") {
|
|
398
|
+
const closingIndex = findClosingDelimiter(command, index + 2, ")");
|
|
399
|
+
if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 2, closingIndex)))
|
|
400
|
+
return true;
|
|
401
|
+
if (closingIndex !== -1) index = closingIndex;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (char === "`") {
|
|
406
|
+
const closingIndex = findClosingDelimiter(command, index + 1, "`");
|
|
407
|
+
if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 1, closingIndex)))
|
|
408
|
+
return true;
|
|
409
|
+
if (closingIndex !== -1) index = closingIndex;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return false;
|
|
310
414
|
}
|
|
311
415
|
|
|
312
416
|
function basenameToken(token: string): string {
|
|
313
|
-
|
|
417
|
+
return path.posix.basename(token);
|
|
314
418
|
}
|
|
315
419
|
|
|
316
420
|
function unwrapLeadingWrappers(
|
|
317
|
-
|
|
318
|
-
|
|
421
|
+
tokens: ShellToken[],
|
|
422
|
+
startIndex: number,
|
|
319
423
|
): { 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
|
-
|
|
424
|
+
let index = startIndex;
|
|
425
|
+
|
|
426
|
+
const consumeOptionValue = (
|
|
427
|
+
inlinePrefix: string,
|
|
428
|
+
separateOptions: string[],
|
|
429
|
+
longOption: string,
|
|
430
|
+
): boolean => {
|
|
431
|
+
const token = tokens[index]?.text;
|
|
432
|
+
if (!token) return false;
|
|
433
|
+
if (token === longOption || separateOptions.includes(token)) {
|
|
434
|
+
index += 1;
|
|
435
|
+
if (index < tokens.length) index += 1;
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
if (
|
|
439
|
+
token.startsWith(`${longOption}=`) ||
|
|
440
|
+
(inlinePrefix && token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)
|
|
441
|
+
) {
|
|
442
|
+
index += 1;
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const consumeOptionalLongOption = (
|
|
449
|
+
inlinePrefix: string,
|
|
450
|
+
shortOption: string,
|
|
451
|
+
longOption: string,
|
|
452
|
+
): boolean => {
|
|
453
|
+
const token = tokens[index]?.text;
|
|
454
|
+
if (!token) return false;
|
|
455
|
+
if (token === longOption) {
|
|
456
|
+
index += 1;
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
if (token === shortOption) {
|
|
460
|
+
index += 1;
|
|
461
|
+
if (index < tokens.length) index += 1;
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
if (
|
|
465
|
+
token.startsWith(`${longOption}=`) ||
|
|
466
|
+
(token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)
|
|
467
|
+
) {
|
|
468
|
+
index += 1;
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
return false;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
while (index < tokens.length && isShellAssignmentToken(tokens[index].text)) index += 1;
|
|
475
|
+
while (tokens[index]?.text === "sudo") index += 1;
|
|
476
|
+
|
|
477
|
+
while (index < tokens.length) {
|
|
478
|
+
const current = tokens[index]?.text;
|
|
479
|
+
const currentBase = current ? basenameToken(current) : undefined;
|
|
480
|
+
if (!current) break;
|
|
481
|
+
|
|
482
|
+
if (current === "command") {
|
|
483
|
+
index += 1;
|
|
484
|
+
while (index < tokens.length) {
|
|
485
|
+
const option = tokens[index]?.text;
|
|
486
|
+
if (!option?.startsWith("-")) break;
|
|
487
|
+
if (option === "--") {
|
|
488
|
+
index += 1;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
if (option === "-v" || option === "-V") {
|
|
492
|
+
return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
|
|
493
|
+
}
|
|
494
|
+
index += 1;
|
|
495
|
+
}
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (currentBase === "env") {
|
|
500
|
+
index += 1;
|
|
501
|
+
while (index < tokens.length) {
|
|
502
|
+
const option = tokens[index]?.text;
|
|
503
|
+
if (!option) break;
|
|
504
|
+
if (option === "--") {
|
|
505
|
+
index += 1;
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
if (option === "--help" || option === "--version") {
|
|
509
|
+
return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
|
|
510
|
+
}
|
|
511
|
+
if (isShellAssignmentToken(option)) {
|
|
512
|
+
index += 1;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (consumeOptionValue("-u", ["-u"], "--unset")) continue;
|
|
516
|
+
if (consumeOptionValue("-C", ["-C"], "--chdir")) continue;
|
|
517
|
+
if (!option.startsWith("-")) break;
|
|
518
|
+
index += 1;
|
|
519
|
+
}
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (currentBase === "xargs") {
|
|
524
|
+
index += 1;
|
|
525
|
+
while (index < tokens.length) {
|
|
526
|
+
const option = tokens[index]?.text;
|
|
527
|
+
if (!option) break;
|
|
528
|
+
if (option === "--") {
|
|
529
|
+
index += 1;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
if (option === "--help" || option === "--version") {
|
|
533
|
+
return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
|
|
534
|
+
}
|
|
535
|
+
if (consumeOptionValue("-n", ["-n"], "--max-args")) continue;
|
|
536
|
+
if (consumeOptionValue("-P", ["-P"], "--max-procs")) continue;
|
|
537
|
+
if (consumeOptionalLongOption("-I", "-I", "--replace")) continue;
|
|
538
|
+
if (consumeOptionValue("-a", ["-a"], "--arg-file")) continue;
|
|
539
|
+
if (consumeOptionalLongOption("-E", "-E", "--eof")) continue;
|
|
540
|
+
if (consumeOptionValue("-s", ["-s"], "--max-chars")) continue;
|
|
541
|
+
if (consumeOptionValue("-d", ["-d"], "--delimiter")) continue;
|
|
542
|
+
if (consumeOptionalLongOption("-L", "-L", "--max-lines")) continue;
|
|
543
|
+
if (!option.startsWith("-")) break;
|
|
544
|
+
index += 1;
|
|
545
|
+
}
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { headIndex: index, nonExecuting: false, consumed: index - startIndex };
|
|
435
553
|
}
|
|
436
554
|
|
|
437
555
|
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
|
-
|
|
556
|
+
const commandName = tokens[headIndex];
|
|
557
|
+
if (!commandName || basenameToken(commandName.text) !== "rm") return false;
|
|
558
|
+
|
|
559
|
+
let sawRecursive = false;
|
|
560
|
+
let sawForce = false;
|
|
561
|
+
|
|
562
|
+
for (let index = headIndex + 1; index < tokens.length; index += 1) {
|
|
563
|
+
const token = tokens[index]?.text;
|
|
564
|
+
if (!token) continue;
|
|
565
|
+
if (token === "--") break;
|
|
566
|
+
if (!token.startsWith("-") || token === "-") continue;
|
|
567
|
+
if (token === "--recursive") sawRecursive = true;
|
|
568
|
+
if (token === "--force") sawForce = true;
|
|
569
|
+
if (/^-[^-]+$/.test(token)) {
|
|
570
|
+
const flags = token.slice(1);
|
|
571
|
+
if (flags.includes("r") || flags.includes("R")) sawRecursive = true;
|
|
572
|
+
if (flags.includes("f")) sawForce = true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return sawRecursive && sawForce;
|
|
459
577
|
}
|
|
460
578
|
|
|
461
579
|
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
|
-
|
|
580
|
+
if (detectRecursiveForceRm(tokens, headIndex)) return true;
|
|
581
|
+
|
|
582
|
+
const firstToken = tokens[headIndex];
|
|
583
|
+
if (!firstToken) return false;
|
|
584
|
+
|
|
585
|
+
if (["sh", "bash", "zsh", "dash", "ksh"].includes(basenameToken(firstToken.text))) {
|
|
586
|
+
for (let index = headIndex + 1; index < tokens.length; index += 1) {
|
|
587
|
+
const token = tokens[index]?.text;
|
|
588
|
+
if (!token) break;
|
|
589
|
+
if (token === "--") break;
|
|
590
|
+
if (token === "-c" || (/^-[A-Za-z]+$/.test(token) && token.includes("c"))) {
|
|
591
|
+
const script = tokens[index + 1]?.text;
|
|
592
|
+
return typeof script === "string" && hasDangerousRecursiveRm(script);
|
|
593
|
+
}
|
|
594
|
+
if (!token.startsWith("-")) break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (firstToken.text === "eval") {
|
|
599
|
+
return (
|
|
600
|
+
headIndex + 1 < tokens.length &&
|
|
601
|
+
hasDangerousRecursiveRm(
|
|
602
|
+
tokens
|
|
603
|
+
.slice(headIndex + 1)
|
|
604
|
+
.map(({ text }) => text)
|
|
605
|
+
.join(" "),
|
|
606
|
+
)
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return false;
|
|
485
611
|
}
|
|
486
612
|
|
|
487
613
|
function hasDangerousCommandSuffix(tokens: ShellToken[]): boolean {
|
|
488
|
-
|
|
614
|
+
let index = 0;
|
|
489
615
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
616
|
+
while (index < tokens.length) {
|
|
617
|
+
const unwrapped = unwrapLeadingWrappers(tokens, index);
|
|
618
|
+
if (unwrapped.nonExecuting) return false;
|
|
619
|
+
if (hasDangerousCommandHead(tokens, unwrapped.headIndex)) return true;
|
|
494
620
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
621
|
+
// Check every possible executable head once, while skipping known-wrapper
|
|
622
|
+
// option values so data such as an xargs EOF marker is never treated as a command.
|
|
623
|
+
index += unwrapped.consumed + 1;
|
|
624
|
+
}
|
|
499
625
|
|
|
500
|
-
|
|
626
|
+
return false;
|
|
501
627
|
}
|
|
502
628
|
|
|
503
629
|
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
|
-
|
|
630
|
+
// Conservative detector: recurse through obvious command-substitution and shell-wrapper
|
|
631
|
+
// surfaces, then inspect argv-like tokens for rm plus recursive+force semantics.
|
|
632
|
+
if (hasDangerousSubstitution(command)) return true;
|
|
633
|
+
|
|
634
|
+
const tokens = tokenizeShellWords(command);
|
|
635
|
+
let currentCommand: ShellToken[] = [];
|
|
636
|
+
let findExecCommand: ShellToken[] | undefined;
|
|
637
|
+
|
|
638
|
+
const evaluateTokens = (commandTokens: ShellToken[]): boolean =>
|
|
639
|
+
hasDangerousCommandSuffix(commandTokens);
|
|
640
|
+
|
|
641
|
+
const resetCurrentCommand = () => {
|
|
642
|
+
currentCommand = [];
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
for (const token of tokens) {
|
|
646
|
+
if (findExecCommand) {
|
|
647
|
+
if (token.text === ";" || token.text === "+") {
|
|
648
|
+
if (evaluateTokens(findExecCommand)) return true;
|
|
649
|
+
findExecCommand = undefined;
|
|
650
|
+
resetCurrentCommand();
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
findExecCommand.push(token);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (token.text === "-exec" || token.text === "-execdir") {
|
|
658
|
+
findExecCommand = [];
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (shellCommandSeparators.has(token.text)) {
|
|
663
|
+
if (evaluateTokens(currentCommand)) return true;
|
|
664
|
+
resetCurrentCommand();
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
currentCommand.push(token);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (findExecCommand && evaluateTokens(findExecCommand)) return true;
|
|
672
|
+
return evaluateTokens(currentCommand);
|
|
546
673
|
}
|
|
547
674
|
|
|
548
675
|
function validateShellInput(input: unknown): ShellInput | undefined {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
676
|
+
if (!isRecord(input) || typeof input.command !== "string" || input.command.trim() === "")
|
|
677
|
+
return undefined;
|
|
678
|
+
if (
|
|
679
|
+
input.timeout !== undefined &&
|
|
680
|
+
(typeof input.timeout !== "number" ||
|
|
681
|
+
!Number.isFinite(input.timeout) ||
|
|
682
|
+
input.timeout <= 0 ||
|
|
683
|
+
input.timeout > MAX_SHELL_TIMEOUT_SECONDS)
|
|
684
|
+
) {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
return input.timeout === undefined
|
|
688
|
+
? { command: input.command }
|
|
689
|
+
: { command: input.command, timeout: input.timeout };
|
|
562
690
|
}
|
|
563
691
|
|
|
564
692
|
async function hasDangerousPowerShellCommand(
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
693
|
+
pi: ExtensionAPI,
|
|
694
|
+
command: string,
|
|
695
|
+
signal?: AbortSignal,
|
|
568
696
|
): 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
|
-
|
|
697
|
+
// Keep both lexical work and Windows' base64-expanded -Command transport
|
|
698
|
+
// bounded below the platform command-line limit. Oversized input is
|
|
699
|
+
// deliberately treated as requiring confirmation rather than failing open.
|
|
700
|
+
if (Buffer.byteLength(command, "utf8") > MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES) return true;
|
|
701
|
+
|
|
702
|
+
const lexicalAnalysis = analyzePowerShellLexically(command);
|
|
703
|
+
if (lexicalAnalysis.risky) return true;
|
|
704
|
+
|
|
705
|
+
// Pi only exposes its built-in PowerShell tool on Windows. On that platform,
|
|
706
|
+
// ask PowerShell's own parser for the final word so ordinary syntax features
|
|
707
|
+
// (module qualification, interpolation, splatting, and backtick escapes) cannot
|
|
708
|
+
// bypass the fallback lexer. Parser failures and malformed output fail closed.
|
|
709
|
+
if (process.platform !== "win32") return false;
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
const { getPowerShellConfig } = await import("@earendil-works/pi-coding-agent");
|
|
713
|
+
const config = getPowerShellConfig();
|
|
714
|
+
const result = await pi.exec(
|
|
715
|
+
config.shell,
|
|
716
|
+
[...config.args, buildPowerShellAstCommand(command)],
|
|
717
|
+
{
|
|
718
|
+
timeout: POWERSHELL_ANALYSIS_TIMEOUT_MS,
|
|
719
|
+
signal,
|
|
720
|
+
},
|
|
721
|
+
);
|
|
722
|
+
if (result.code !== 0 || result.killed) return true;
|
|
723
|
+
return parsePowerShellAstOutput(result.stdout).risky;
|
|
724
|
+
} catch {
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
595
727
|
}
|
|
596
728
|
|
|
597
|
-
function validateWriteInput(input: unknown):
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return { path: input.path, content: input.content };
|
|
729
|
+
function validateWriteInput(input: unknown): NormalizedPath | undefined {
|
|
730
|
+
if (!isRecord(input)) return undefined;
|
|
731
|
+
if (typeof input.path !== "string" || typeof input.content !== "string") return undefined;
|
|
732
|
+
return normalizeToolPath(input.path);
|
|
602
733
|
}
|
|
603
734
|
|
|
604
|
-
function validateEditInput(input: unknown):
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
path: input.path,
|
|
621
|
-
edits: edits as Array<{ oldText: string; newText: string }>,
|
|
622
|
-
};
|
|
735
|
+
function validateEditInput(input: unknown): NormalizedPath | undefined {
|
|
736
|
+
if (!isRecord(input)) return undefined;
|
|
737
|
+
if (typeof input.path !== "string" || !Array.isArray(input.edits)) return undefined;
|
|
738
|
+
const normalizedPath = normalizeToolPath(input.path);
|
|
739
|
+
if (!normalizedPath) return undefined;
|
|
740
|
+
|
|
741
|
+
if (
|
|
742
|
+
input.edits.some(
|
|
743
|
+
(edit) =>
|
|
744
|
+
!isRecord(edit) || typeof edit.oldText !== "string" || typeof edit.newText !== "string",
|
|
745
|
+
)
|
|
746
|
+
) {
|
|
747
|
+
return undefined;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return normalizedPath;
|
|
623
751
|
}
|
|
624
752
|
|
|
625
|
-
async function requestConfirmation(
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
753
|
+
async function requestConfirmation(
|
|
754
|
+
prompt: string,
|
|
755
|
+
ctx: ConfirmationContext,
|
|
756
|
+
): Promise<GuardDecision> {
|
|
757
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
758
|
+
return { block: true, reason: "Confirmation unavailable (no UI)" };
|
|
759
|
+
}
|
|
760
|
+
if (ctx.signal?.aborted) {
|
|
761
|
+
return { block: true, reason: "Confirmation cancelled" };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
const choice = await ctx.ui.select(prompt, ["Yes", "No"], { signal: ctx.signal });
|
|
766
|
+
if (ctx.signal?.aborted) {
|
|
767
|
+
return { block: true, reason: "Confirmation cancelled" };
|
|
768
|
+
}
|
|
769
|
+
return choice === "Yes" ? undefined : { block: true, reason: "Blocked by user" };
|
|
770
|
+
} catch {
|
|
771
|
+
// A dismissed or failed UI must never turn a confirmation failure into an allow.
|
|
772
|
+
return { block: true, reason: "Confirmation unavailable" };
|
|
773
|
+
}
|
|
643
774
|
}
|
|
644
775
|
|
|
645
776
|
async function confirmProtectedPathAction(
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
777
|
+
toolName: "write" | "edit",
|
|
778
|
+
normalizedPath: NormalizedPath,
|
|
779
|
+
ctx: ConfirmationContext,
|
|
780
|
+
alsoProtected = false,
|
|
781
|
+
displaySuffix?: string,
|
|
649
782
|
): Promise<GuardDecision> {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
783
|
+
if (!alsoProtected && !isProtectedPath(normalizedPath)) return undefined;
|
|
784
|
+
const displayLabel = displaySuffix
|
|
785
|
+
? `${normalizedPath.displayPath}${displaySuffix}`
|
|
786
|
+
: normalizedPath.displayPath;
|
|
787
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
788
|
+
return {
|
|
789
|
+
block: true,
|
|
790
|
+
reason: `Protected path blocked (${toolName} without UI confirmation): ${displayLabel}`,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return requestConfirmation(
|
|
795
|
+
`⚠️ Protected path ${toolName} request:\n\n ${displayLabel}\n\nAllow?`,
|
|
796
|
+
ctx,
|
|
797
|
+
);
|
|
659
798
|
}
|
|
660
799
|
|
|
661
800
|
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
|
-
|
|
801
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
802
|
+
if (event.toolName === "bash" || event.toolName === "powershell") {
|
|
803
|
+
const shellToolName = event.toolName;
|
|
804
|
+
const input = validateShellInput(event.input);
|
|
805
|
+
if (!input) {
|
|
806
|
+
return { block: true, reason: `Malformed ${shellToolName} command blocked` };
|
|
807
|
+
}
|
|
808
|
+
const { command } = input;
|
|
809
|
+
|
|
810
|
+
const isDangerous =
|
|
811
|
+
shellToolName === "powershell"
|
|
812
|
+
? await hasDangerousPowerShellCommand(pi, command, ctx.signal)
|
|
813
|
+
: hasDangerousRecursiveRm(command) ||
|
|
814
|
+
dangerousPatterns.some((pattern) => pattern.test(command));
|
|
815
|
+
|
|
816
|
+
if (isDangerous) {
|
|
817
|
+
if (!ctx.hasUI) {
|
|
818
|
+
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return requestConfirmation(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ctx);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (event.toolName === "write") {
|
|
828
|
+
const normalizedPath = validateWriteInput(event.input);
|
|
829
|
+
if (!normalizedPath) {
|
|
830
|
+
return { block: true, reason: "Malformed write input blocked" };
|
|
831
|
+
}
|
|
832
|
+
// Monotonic hardening: also check via Pi-style path resolution against ctx.cwd.
|
|
833
|
+
// OR logic: protected if EITHER the lexical check OR the Pi-resolved check matches.
|
|
834
|
+
// When only the Pi-resolved check catches a path, show the resolved absolute path in
|
|
835
|
+
// the reason so the user understands why the plain-looking path is protected.
|
|
836
|
+
const lexicalProtected = isProtectedPath(normalizedPath);
|
|
837
|
+
const piResult = checkPiResolvedPath(normalizedPath.original, ctx.cwd ?? process.cwd());
|
|
838
|
+
const piAlsoProtected = piResult.status === "protected" || piResult.status === "error";
|
|
839
|
+
const displaySuffix = lexicalProtected
|
|
840
|
+
? undefined
|
|
841
|
+
: piResult.status === "protected"
|
|
842
|
+
? ` (resolves to ${piResult.resolvedPath})`
|
|
843
|
+
: piResult.status === "error"
|
|
844
|
+
? " (path could not be resolved)"
|
|
845
|
+
: undefined;
|
|
846
|
+
return confirmProtectedPathAction(
|
|
847
|
+
"write",
|
|
848
|
+
normalizedPath,
|
|
849
|
+
ctx,
|
|
850
|
+
piAlsoProtected,
|
|
851
|
+
displaySuffix,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (event.toolName === "edit") {
|
|
856
|
+
const normalizedPath = validateEditInput(event.input);
|
|
857
|
+
if (!normalizedPath) {
|
|
858
|
+
return { block: true, reason: "Malformed edit input blocked" };
|
|
859
|
+
}
|
|
860
|
+
// Monotonic hardening: also check via Pi-style path resolution against ctx.cwd.
|
|
861
|
+
// OR logic: protected if EITHER the lexical check OR the Pi-resolved check matches.
|
|
862
|
+
// When only the Pi-resolved check catches a path, show the resolved absolute path in
|
|
863
|
+
// the reason so the user understands why the plain-looking path is protected.
|
|
864
|
+
const lexicalProtected = isProtectedPath(normalizedPath);
|
|
865
|
+
const piResult = checkPiResolvedPath(normalizedPath.original, ctx.cwd ?? process.cwd());
|
|
866
|
+
const piAlsoProtected = piResult.status === "protected" || piResult.status === "error";
|
|
867
|
+
const displaySuffix = lexicalProtected
|
|
868
|
+
? undefined
|
|
869
|
+
: piResult.status === "protected"
|
|
870
|
+
? ` (resolves to ${piResult.resolvedPath})`
|
|
871
|
+
: piResult.status === "error"
|
|
872
|
+
? " (path could not be resolved)"
|
|
873
|
+
: undefined;
|
|
874
|
+
return confirmProtectedPathAction(
|
|
875
|
+
"edit",
|
|
876
|
+
normalizedPath,
|
|
877
|
+
ctx,
|
|
878
|
+
piAlsoProtected,
|
|
879
|
+
displaySuffix,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return undefined;
|
|
884
|
+
});
|
|
712
885
|
}
|