@czottmann/pi-automode 1.1.0 → 1.2.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/extensions/auto-mode/classifier.ts +152 -0
- package/extensions/auto-mode/config.ts +399 -0
- package/extensions/auto-mode/constants.ts +168 -0
- package/extensions/auto-mode/extension.ts +402 -0
- package/extensions/auto-mode/hard-deny.ts +348 -0
- package/extensions/auto-mode/model-selector.ts +113 -0
- package/extensions/auto-mode/model.ts +13 -0
- package/extensions/auto-mode/paths.ts +134 -0
- package/extensions/auto-mode/permissions.ts +90 -0
- package/extensions/auto-mode/state.ts +103 -0
- package/extensions/auto-mode/transcript.ts +88 -0
- package/extensions/auto-mode/types.ts +95 -0
- package/extensions/auto-mode/utils.ts +46 -0
- package/extensions/auto-mode.ts +14 -1951
- package/package.json +2 -2
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { HOME } from "./constants.ts";
|
|
3
|
+
import {
|
|
4
|
+
isProfileOrAuthorizedKeysPath,
|
|
5
|
+
isSafetyControlPath,
|
|
6
|
+
resolveInputPath,
|
|
7
|
+
shellPathTokenToPath,
|
|
8
|
+
} from "./paths.ts";
|
|
9
|
+
|
|
10
|
+
type ShellSegment = {
|
|
11
|
+
text: string;
|
|
12
|
+
words: string[];
|
|
13
|
+
redirectTargets: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function splitShellSegments(command: string): string[] {
|
|
17
|
+
const segments: string[] = [];
|
|
18
|
+
let current = "";
|
|
19
|
+
let quote: "'" | '"' | "`" | undefined;
|
|
20
|
+
let escaped = false;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
23
|
+
const char = command[i] ?? "";
|
|
24
|
+
const next = command[i + 1] ?? "";
|
|
25
|
+
if (escaped) {
|
|
26
|
+
current += char;
|
|
27
|
+
escaped = false;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (char === "\\" && quote !== "'") {
|
|
31
|
+
current += char;
|
|
32
|
+
escaped = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (quote) {
|
|
36
|
+
current += char;
|
|
37
|
+
if (char === quote) quote = undefined;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
41
|
+
quote = char;
|
|
42
|
+
current += char;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
char === ";" ||
|
|
47
|
+
char === "\n" ||
|
|
48
|
+
char === "|" ||
|
|
49
|
+
(char === "&" && next === "&") ||
|
|
50
|
+
(char === "|" && next === "|")
|
|
51
|
+
) {
|
|
52
|
+
if (current.trim()) segments.push(current.trim());
|
|
53
|
+
current = "";
|
|
54
|
+
if ((char === "&" && next === "&") || (char === "|" && next === "|")) {
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
current += char;
|
|
60
|
+
}
|
|
61
|
+
if (current.trim()) segments.push(current.trim());
|
|
62
|
+
return segments;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tokenizeShellSegment(text: string): string[] {
|
|
66
|
+
const tokens: string[] = [];
|
|
67
|
+
let current = "";
|
|
68
|
+
let quote: "'" | '"' | "`" | undefined;
|
|
69
|
+
let escaped = false;
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
72
|
+
const char = text[i] ?? "";
|
|
73
|
+
if (escaped) {
|
|
74
|
+
current += char;
|
|
75
|
+
escaped = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (char === "\\" && quote !== "'") {
|
|
79
|
+
escaped = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (quote) {
|
|
83
|
+
if (char === quote) quote = undefined;
|
|
84
|
+
else current += char;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
88
|
+
quote = char;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (/\s/.test(char)) {
|
|
92
|
+
if (current) tokens.push(current);
|
|
93
|
+
current = "";
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === ">" || char === "<") {
|
|
97
|
+
let op = char;
|
|
98
|
+
if (/^\d+$/.test(current)) {
|
|
99
|
+
op = current + char;
|
|
100
|
+
} else if (current) {
|
|
101
|
+
tokens.push(current);
|
|
102
|
+
}
|
|
103
|
+
if (text[i + 1] === ">" || text[i + 1] === "&") {
|
|
104
|
+
op += text[i + 1];
|
|
105
|
+
i += 1;
|
|
106
|
+
}
|
|
107
|
+
tokens.push(op);
|
|
108
|
+
current = "";
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
current += char;
|
|
112
|
+
}
|
|
113
|
+
if (current) tokens.push(current);
|
|
114
|
+
return tokens;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseShell(command: string): ShellSegment[] {
|
|
118
|
+
return splitShellSegments(command).map((text) => {
|
|
119
|
+
const tokens = tokenizeShellSegment(text);
|
|
120
|
+
const words: string[] = [];
|
|
121
|
+
const redirectTargets: string[] = [];
|
|
122
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
123
|
+
const token = tokens[i] ?? "";
|
|
124
|
+
if (/^(?:\d?>|\d?>>|>|>>|&>|<)$/.test(token)) {
|
|
125
|
+
const target = tokens[i + 1];
|
|
126
|
+
if (target) redirectTargets.push(target);
|
|
127
|
+
i += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const attachedRedirect = token.match(/^(?:\d?>|\d?>>|>|>>|&>)(.+)$/);
|
|
131
|
+
if (attachedRedirect?.[1]) {
|
|
132
|
+
redirectTargets.push(attachedRedirect[1]);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
words.push(token);
|
|
136
|
+
}
|
|
137
|
+
return { text, words, redirectTargets };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function commandName(words: string[]): string | undefined {
|
|
142
|
+
return words.find((word) => !/^\w+=/.test(word));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function commandArgs(words: string[]): string[] {
|
|
146
|
+
const index = words.findIndex((word) => !/^\w+=/.test(word));
|
|
147
|
+
return index >= 0 ? words.slice(index + 1) : [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isRecursiveRmArg(arg: string): boolean {
|
|
151
|
+
return (
|
|
152
|
+
arg === "--recursive" ||
|
|
153
|
+
/^-[A-Za-z]*r[A-Za-z]*f?[A-Za-z]*$/.test(arg) ||
|
|
154
|
+
/^-[A-Za-z]*f[A-Za-z]*r[A-Za-z]*$/.test(arg)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isRootHomeOrSystemPath(path: string): boolean {
|
|
159
|
+
const systemRoots = [
|
|
160
|
+
"/bin",
|
|
161
|
+
"/boot",
|
|
162
|
+
"/dev",
|
|
163
|
+
"/etc",
|
|
164
|
+
"/lib",
|
|
165
|
+
"/lib64",
|
|
166
|
+
"/private",
|
|
167
|
+
"/sbin",
|
|
168
|
+
"/sys",
|
|
169
|
+
"/usr",
|
|
170
|
+
"/var",
|
|
171
|
+
];
|
|
172
|
+
return (
|
|
173
|
+
path === "/" ||
|
|
174
|
+
path === HOME ||
|
|
175
|
+
systemRoots.some((root) => path === root || path.startsWith(`${root}/`))
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function segmentHardDeny(
|
|
180
|
+
segment: ShellSegment,
|
|
181
|
+
cwd: string,
|
|
182
|
+
): string | undefined {
|
|
183
|
+
for (const target of segment.redirectTargets) {
|
|
184
|
+
const path = shellPathTokenToPath(target, cwd);
|
|
185
|
+
if (!path) continue;
|
|
186
|
+
const profileReason = isProfileOrAuthorizedKeysPath(path);
|
|
187
|
+
if (profileReason) return profileReason;
|
|
188
|
+
if (isSafetyControlPath(path, cwd)) {
|
|
189
|
+
return "auto-mode or permission safety-control modification is hard-denied";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const word of segment.words) {
|
|
194
|
+
if (
|
|
195
|
+
/^(NODE_TLS_REJECT_UNAUTHORIZED=0|GIT_SSL_NO_VERIFY=(1|true))$/i.test(
|
|
196
|
+
word,
|
|
197
|
+
)
|
|
198
|
+
) {
|
|
199
|
+
return "TLS verification weakening is hard-denied";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const name = commandName(segment.words);
|
|
204
|
+
if (!name) return undefined;
|
|
205
|
+
const args = commandArgs(segment.words);
|
|
206
|
+
const lowerArgs = args.map((arg) => arg.toLowerCase());
|
|
207
|
+
|
|
208
|
+
if (
|
|
209
|
+
["curl", "wget"].includes(name) &&
|
|
210
|
+
lowerArgs.some((arg) =>
|
|
211
|
+
["--insecure", "-k", "--no-check-certificate"].includes(arg)
|
|
212
|
+
)
|
|
213
|
+
) {
|
|
214
|
+
return "certificate verification weakening is hard-denied";
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
["npm", "yarn", "pnpm"].includes(name) &&
|
|
218
|
+
lowerArgs[0] === "config" &&
|
|
219
|
+
lowerArgs[1] === "set" &&
|
|
220
|
+
["strict-ssl", "cafile"].includes(lowerArgs[2] ?? "") &&
|
|
221
|
+
["false", "null"].includes(lowerArgs[3] ?? "")
|
|
222
|
+
) {
|
|
223
|
+
return "package-manager TLS weakening is hard-denied";
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
name === "git" &&
|
|
227
|
+
lowerArgs[0] === "config" &&
|
|
228
|
+
lowerArgs.some(
|
|
229
|
+
(arg) => arg === "sslverify" || arg.endsWith(".sslverify"),
|
|
230
|
+
) &&
|
|
231
|
+
lowerArgs.includes("false")
|
|
232
|
+
) {
|
|
233
|
+
return "git TLS verification weakening is hard-denied";
|
|
234
|
+
}
|
|
235
|
+
if (name === "crontab" && !lowerArgs.includes("-l")) {
|
|
236
|
+
return "persistence or system service mutation is hard-denied";
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
name === "launchctl" &&
|
|
240
|
+
["load", "bootstrap", "enable"].includes(lowerArgs[0] ?? "")
|
|
241
|
+
) {
|
|
242
|
+
return "persistence or system service mutation is hard-denied";
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
name === "systemctl" &&
|
|
246
|
+
["enable", "disable"].includes(lowerArgs[0] ?? "")
|
|
247
|
+
) {
|
|
248
|
+
return "persistence or system service mutation is hard-denied";
|
|
249
|
+
}
|
|
250
|
+
if (name === "security" && lowerArgs[0] === "add-trusted-cert") {
|
|
251
|
+
return "platform security weakening is hard-denied";
|
|
252
|
+
}
|
|
253
|
+
if (name === "spctl" && lowerArgs.includes("--master-disable")) {
|
|
254
|
+
return "platform security weakening is hard-denied";
|
|
255
|
+
}
|
|
256
|
+
if (name === "csrutil" && lowerArgs[0] === "disable") {
|
|
257
|
+
return "platform security weakening is hard-denied";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (name === "rm" && args.some(isRecursiveRmArg)) {
|
|
261
|
+
for (const arg of args.filter((arg) => !arg.startsWith("-"))) {
|
|
262
|
+
const path = shellPathTokenToPath(arg, cwd);
|
|
263
|
+
if (path && isRootHomeOrSystemPath(path)) {
|
|
264
|
+
return "irreversible deletion of home/root/system paths is hard-denied";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (name === "find" && lowerArgs.includes("-delete")) {
|
|
270
|
+
const root = shellPathTokenToPath(args[0] ?? "", cwd);
|
|
271
|
+
if (root && isRootHomeOrSystemPath(root) && root !== HOME) {
|
|
272
|
+
return "system-wide delete is hard-denied";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (["chmod", "chown"].includes(name)) {
|
|
277
|
+
for (const arg of args.filter((arg) => !arg.startsWith("-"))) {
|
|
278
|
+
const path = shellPathTokenToPath(arg, cwd);
|
|
279
|
+
if (
|
|
280
|
+
path &&
|
|
281
|
+
(path.startsWith("/etc/") ||
|
|
282
|
+
path.startsWith("/usr/") ||
|
|
283
|
+
path.startsWith("/bin/") ||
|
|
284
|
+
path.startsWith("/sbin/") ||
|
|
285
|
+
path.startsWith("/System/") ||
|
|
286
|
+
path.startsWith(resolve(HOME, ".ssh")))
|
|
287
|
+
) {
|
|
288
|
+
return "system or SSH permission mutation is hard-denied";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (
|
|
294
|
+
[
|
|
295
|
+
"tee",
|
|
296
|
+
"mv",
|
|
297
|
+
"cp",
|
|
298
|
+
"rm",
|
|
299
|
+
"unlink",
|
|
300
|
+
"truncate",
|
|
301
|
+
"python",
|
|
302
|
+
"python3",
|
|
303
|
+
"node",
|
|
304
|
+
"perl",
|
|
305
|
+
"ruby",
|
|
306
|
+
"sd",
|
|
307
|
+
"sed",
|
|
308
|
+
].includes(name) &&
|
|
309
|
+
/\.pi\/automode|\.pi\/extensions|pi-automode|auto-mode\.json/i.test(
|
|
310
|
+
segment.text,
|
|
311
|
+
)
|
|
312
|
+
) {
|
|
313
|
+
return "auto-mode or permission safety-control modification is hard-denied";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Deterministic deny checks for actions too risky to delegate to the classifier.
|
|
321
|
+
*
|
|
322
|
+
* Bash checks use a small shell lexer instead of only regexes. It is not a full
|
|
323
|
+
* POSIX shell implementation, but it handles quotes, redirects, pipes, `&&`, and
|
|
324
|
+
* `;` well enough to avoid the common "safe prefix hides risky suffix" bypass.
|
|
325
|
+
*/
|
|
326
|
+
export function deterministicHardDeny(
|
|
327
|
+
toolName: string,
|
|
328
|
+
input: Record<string, unknown>,
|
|
329
|
+
cwd: string,
|
|
330
|
+
): string | undefined {
|
|
331
|
+
if (toolName === "write" || toolName === "edit") {
|
|
332
|
+
const path = resolveInputPath(cwd, input.path);
|
|
333
|
+
if (!path) return undefined;
|
|
334
|
+
const profileReason = isProfileOrAuthorizedKeysPath(path);
|
|
335
|
+
if (profileReason) return profileReason;
|
|
336
|
+
if (isSafetyControlPath(path, cwd)) {
|
|
337
|
+
return "auto-mode or permission safety-control modification is hard-denied";
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (toolName !== "bash") return undefined;
|
|
342
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
343
|
+
for (const segment of parseShell(command)) {
|
|
344
|
+
const reason = segmentHardDeny(segment, cwd);
|
|
345
|
+
if (reason) return reason;
|
|
346
|
+
}
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
fuzzyFilter,
|
|
4
|
+
Input,
|
|
5
|
+
matchesKey,
|
|
6
|
+
SelectList,
|
|
7
|
+
} from "@earendil-works/pi-tui";
|
|
8
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
9
|
+
import { formatModelSpec } from "./model.ts";
|
|
10
|
+
|
|
11
|
+
/** Interactive model selector shown when `/automode model` is run without arguments. */
|
|
12
|
+
export function promptForClassifierModel(
|
|
13
|
+
ctx: ExtensionContext,
|
|
14
|
+
current?: string,
|
|
15
|
+
): Promise<string | undefined> {
|
|
16
|
+
if (!ctx.hasUI) {
|
|
17
|
+
return Promise.resolve(undefined);
|
|
18
|
+
}
|
|
19
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
20
|
+
if (available.length === 0) {
|
|
21
|
+
return Promise.resolve(undefined);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const items: SelectItem[] = available.map((model) => {
|
|
25
|
+
const spec = formatModelSpec(model);
|
|
26
|
+
return {
|
|
27
|
+
value: spec,
|
|
28
|
+
label: `${model.id} \u001b[2m[${model.provider}]\u001b[0m`,
|
|
29
|
+
description: spec === current ? "\u2713" : undefined,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
items.sort((a, b) => a.label.localeCompare(b.label));
|
|
33
|
+
|
|
34
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
35
|
+
const filterInput = new Input();
|
|
36
|
+
filterInput.onEscape = () => done(undefined);
|
|
37
|
+
|
|
38
|
+
let filtered: SelectItem[] = items;
|
|
39
|
+
let selectList = buildModelList(filtered, theme, filterInput, done, tui);
|
|
40
|
+
|
|
41
|
+
function applyFilter(query: string): void {
|
|
42
|
+
filtered = query
|
|
43
|
+
? fuzzyFilter(items, query, (item) => `${item.label} ${item.value}`)
|
|
44
|
+
: items;
|
|
45
|
+
selectList = buildModelList(filtered, theme, filterInput, done, tui);
|
|
46
|
+
tui.requestRender();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
render(width: number) {
|
|
51
|
+
const selected = selectList.getSelectedItem();
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
lines.push(theme.fg("accent", theme.bold("Select classifier model")));
|
|
54
|
+
lines.push(
|
|
55
|
+
theme.fg(
|
|
56
|
+
"dim",
|
|
57
|
+
"Only showing models from configured providers. Use /login to add providers.",
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(filterInput.render(width).join("\n"));
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push(...selectList.render(width));
|
|
64
|
+
lines.push("");
|
|
65
|
+
if (selected) {
|
|
66
|
+
lines.push(theme.fg("muted", `Model Name: ${selected.label}`));
|
|
67
|
+
}
|
|
68
|
+
return lines;
|
|
69
|
+
},
|
|
70
|
+
invalidate() {
|
|
71
|
+
/* no-op */
|
|
72
|
+
},
|
|
73
|
+
handleInput(data: string) {
|
|
74
|
+
if (
|
|
75
|
+
matchesKey(data, "up") || matchesKey(data, "down") ||
|
|
76
|
+
matchesKey(data, "return") || matchesKey(data, "escape")
|
|
77
|
+
) {
|
|
78
|
+
selectList.handleInput(data);
|
|
79
|
+
tui.requestRender();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
filterInput.handleInput(data);
|
|
83
|
+
applyFilter(filterInput.getValue());
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildModelList(
|
|
90
|
+
items: SelectItem[],
|
|
91
|
+
theme: any,
|
|
92
|
+
filterInput: Input,
|
|
93
|
+
done: (value: string | undefined) => void,
|
|
94
|
+
tui: any,
|
|
95
|
+
): SelectList {
|
|
96
|
+
const maxVisible = Math.min(10, Math.max(1, items.length));
|
|
97
|
+
const list = new SelectList(items, maxVisible, {
|
|
98
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
99
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
100
|
+
description: (text) => theme.fg("muted", text),
|
|
101
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
102
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
103
|
+
});
|
|
104
|
+
list.setSelectedIndex(0);
|
|
105
|
+
list.onCancel = () => done(undefined);
|
|
106
|
+
list.onSelect = (item) => done(item.value);
|
|
107
|
+
list.onSelectionChange = () => tui.requestRender();
|
|
108
|
+
filterInput.onSubmit = () => {
|
|
109
|
+
const selected = list.getSelectedItem();
|
|
110
|
+
if (selected) done(selected.value);
|
|
111
|
+
};
|
|
112
|
+
return list;
|
|
113
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export function parseModelSpec(
|
|
4
|
+
spec: string,
|
|
5
|
+
): { provider: string; id: string } | undefined {
|
|
6
|
+
const slash = spec.indexOf("/");
|
|
7
|
+
if (slash <= 0 || slash >= spec.length - 1) return undefined;
|
|
8
|
+
return { provider: spec.slice(0, slash), id: spec.slice(slash + 1) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatModelSpec(model: Model<any>): string {
|
|
12
|
+
return `${model.provider}/${model.id}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
basename,
|
|
4
|
+
dirname,
|
|
5
|
+
isAbsolute,
|
|
6
|
+
join,
|
|
7
|
+
normalize,
|
|
8
|
+
relative,
|
|
9
|
+
resolve,
|
|
10
|
+
} from "node:path";
|
|
11
|
+
import { HOME, PROFILE_FILES } from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
function stripLeadingAt(value: string): string {
|
|
14
|
+
return value.startsWith("@") ? value.slice(1) : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveInputPath(
|
|
18
|
+
cwd: string,
|
|
19
|
+
value: unknown,
|
|
20
|
+
): string | undefined {
|
|
21
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
22
|
+
const raw = stripLeadingAt(value.trim());
|
|
23
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizePathForMatch(path: string, cwd: string): string {
|
|
27
|
+
const normalized = normalize(path);
|
|
28
|
+
const rel = relative(cwd, normalized);
|
|
29
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : normalized;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isInside(child: string, parent: string): boolean {
|
|
33
|
+
const rel = relative(parent, child);
|
|
34
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isProtectedPath(
|
|
38
|
+
path: string,
|
|
39
|
+
cwd: string,
|
|
40
|
+
protectedPaths: string[],
|
|
41
|
+
): boolean {
|
|
42
|
+
// Resolve symlinks so writes through symlinks (e.g. not-git -> .git) are caught.
|
|
43
|
+
let resolved = path;
|
|
44
|
+
try {
|
|
45
|
+
resolved = realpathSync(path);
|
|
46
|
+
} catch {
|
|
47
|
+
// File doesn't exist yet — try resolving the parent directory.
|
|
48
|
+
try {
|
|
49
|
+
const dir = dirname(path);
|
|
50
|
+
const base = basename(path);
|
|
51
|
+
resolved = join(realpathSync(dir), base);
|
|
52
|
+
} catch {
|
|
53
|
+
// Parent doesn't exist either — fall through with raw path.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// For paths inside the project: use relative path for matching.
|
|
58
|
+
if (resolved.startsWith(cwd)) {
|
|
59
|
+
const relativePath = relative(cwd, resolved);
|
|
60
|
+
for (const pattern of protectedPaths) {
|
|
61
|
+
if (
|
|
62
|
+
relativePath === pattern ||
|
|
63
|
+
relativePath.startsWith(`${pattern}/`)
|
|
64
|
+
) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// For paths outside the project: check every path component suffix.
|
|
72
|
+
// This catches writes like ../other-project/.git/config even when cwd
|
|
73
|
+
// doesn't contain the target.
|
|
74
|
+
const segments = resolved.split("/").filter(Boolean);
|
|
75
|
+
for (let i = 0; i < segments.length; i++) {
|
|
76
|
+
const suffix = segments.slice(i).join("/");
|
|
77
|
+
for (const pattern of protectedPaths) {
|
|
78
|
+
if (
|
|
79
|
+
suffix === pattern ||
|
|
80
|
+
suffix.startsWith(`${pattern}/`)
|
|
81
|
+
) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
90
|
+
const normalized = path.replace(/\\/g, "/");
|
|
91
|
+
const file = basename(normalized).toLowerCase();
|
|
92
|
+
if (
|
|
93
|
+
normalized.endsWith("/.pi/auto-mode.json") ||
|
|
94
|
+
normalized.endsWith("/auto-mode.json")
|
|
95
|
+
) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (normalized.includes("/.pi/extensions/") && file.includes("auto")) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (normalized.includes("/.pi/") && file.startsWith("automode")) return true;
|
|
102
|
+
if (
|
|
103
|
+
normalized.includes("/pi-automode/") ||
|
|
104
|
+
(isInside(path, cwd) && file.includes("auto-mode"))
|
|
105
|
+
) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function shellPathTokenToPath(
|
|
112
|
+
token: string,
|
|
113
|
+
cwd: string,
|
|
114
|
+
): string | undefined {
|
|
115
|
+
let value = token.trim();
|
|
116
|
+
if (!value || value === "-" || value.startsWith("&")) return undefined;
|
|
117
|
+
value = value
|
|
118
|
+
.replace(/^\$HOME(?=\/|$)/, HOME)
|
|
119
|
+
.replace(/^\$\{HOME\}(?=\/|$)/, HOME);
|
|
120
|
+
if (value.startsWith("~/")) value = resolve(HOME, value.slice(2));
|
|
121
|
+
return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isProfileOrAuthorizedKeysPath(
|
|
125
|
+
path: string,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
if (PROFILE_FILES.has(path)) {
|
|
128
|
+
return "shell profile modification is hard-denied";
|
|
129
|
+
}
|
|
130
|
+
if (path === resolve(HOME, ".ssh/authorized_keys")) {
|
|
131
|
+
return "SSH authorized_keys modification is hard-denied";
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ToolPattern } from "./types.ts";
|
|
2
|
+
import { normalizePathForMatch, resolveInputPath } from "./paths.ts";
|
|
3
|
+
|
|
4
|
+
function normalizeToolName(name: string): string {
|
|
5
|
+
const lower = name.trim().replace(/^@/, "").toLowerCase();
|
|
6
|
+
const aliases: Record<string, string> = {
|
|
7
|
+
bash: "bash",
|
|
8
|
+
read: "read",
|
|
9
|
+
edit: "edit",
|
|
10
|
+
write: "write",
|
|
11
|
+
grep: "grep",
|
|
12
|
+
find: "find",
|
|
13
|
+
ls: "ls",
|
|
14
|
+
};
|
|
15
|
+
return aliases[lower] ?? lower;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse Pi permission entries such as `bash(git push *)`.
|
|
20
|
+
*
|
|
21
|
+
* Capitalized names such as `Bash(...)` are accepted as a convenience, but Pi's
|
|
22
|
+
* actual tool names are lowercase. Scoped entries stay scoped: we do not flatten
|
|
23
|
+
* `bash(git status *)` into a blanket `bash` permission.
|
|
24
|
+
*/
|
|
25
|
+
export function parseToolPattern(value: unknown): ToolPattern | undefined {
|
|
26
|
+
if (typeof value !== "string") return undefined;
|
|
27
|
+
const raw = value.trim();
|
|
28
|
+
if (!raw) return undefined;
|
|
29
|
+
|
|
30
|
+
const match = raw.match(/^@?([A-Za-z0-9_-]+)(?:\((.*)\))?$/s);
|
|
31
|
+
if (!match) return { raw };
|
|
32
|
+
return {
|
|
33
|
+
raw,
|
|
34
|
+
toolName: normalizeToolName(match[1] ?? ""),
|
|
35
|
+
argumentPattern: match[2],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function wildcardToRegExp(pattern: string): RegExp {
|
|
40
|
+
const escaped = pattern
|
|
41
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
42
|
+
.replace(/\*/g, ".*");
|
|
43
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getPrimaryArgument(
|
|
47
|
+
toolName: string,
|
|
48
|
+
input: Record<string, unknown>,
|
|
49
|
+
cwd: string,
|
|
50
|
+
): string {
|
|
51
|
+
if (toolName === "bash" && typeof input.command === "string") {
|
|
52
|
+
return input.command;
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
(toolName === "read" || toolName === "write" || toolName === "edit") &&
|
|
56
|
+
typeof input.path === "string"
|
|
57
|
+
) {
|
|
58
|
+
return normalizePathForMatch(
|
|
59
|
+
resolveInputPath(cwd, input.path) ?? input.path,
|
|
60
|
+
cwd,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (toolName === "grep" && typeof input.pattern === "string") {
|
|
64
|
+
return input.pattern;
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
(toolName === "find" || toolName === "ls") &&
|
|
68
|
+
typeof input.path === "string"
|
|
69
|
+
) {
|
|
70
|
+
return normalizePathForMatch(
|
|
71
|
+
resolveInputPath(cwd, input.path) ?? input.path,
|
|
72
|
+
cwd,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify(input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Match a scoped permission rule against a concrete tool call. */
|
|
79
|
+
export function matchesToolPattern(
|
|
80
|
+
pattern: ToolPattern,
|
|
81
|
+
toolName: string,
|
|
82
|
+
input: Record<string, unknown>,
|
|
83
|
+
cwd: string,
|
|
84
|
+
): boolean {
|
|
85
|
+
if (!pattern.toolName) return false;
|
|
86
|
+
if (pattern.toolName !== normalizeToolName(toolName)) return false;
|
|
87
|
+
if (!pattern.argumentPattern || pattern.argumentPattern === "*") return true;
|
|
88
|
+
const primary = getPrimaryArgument(toolName, input, cwd);
|
|
89
|
+
return wildcardToRegExp(pattern.argumentPattern).test(primary);
|
|
90
|
+
}
|