@ibartel74/pi-automode-ext 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/LICENSE.md +22 -0
- package/README.md +262 -0
- package/docs/GLOSSARY.md +41 -0
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +46 -0
- package/docs/adr/ADR-002-global-config-in-extension-data-directory.md +60 -0
- package/docs/adr/INDEX.md +6 -0
- package/docs/automode-classifier-flow.md +449 -0
- package/docs/configuration.md +226 -0
- package/docs/defaults.md +178 -0
- package/docs/diagnostics.md +90 -0
- package/docs/observability-logging.md +160 -0
- package/examples/automode.local.json +45 -0
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +940 -0
- package/extensions/auto-mode/config.ts +948 -0
- package/extensions/auto-mode/constants.ts +232 -0
- package/extensions/auto-mode/extension.ts +1118 -0
- package/extensions/auto-mode/hard-deny.ts +429 -0
- package/extensions/auto-mode/jev.ts +338 -0
- package/extensions/auto-mode/log.ts +173 -0
- package/extensions/auto-mode/model-selector.ts +113 -0
- package/extensions/auto-mode/model.ts +13 -0
- package/extensions/auto-mode/paths.ts +303 -0
- package/extensions/auto-mode/permissions.ts +667 -0
- package/extensions/auto-mode/state.ts +106 -0
- package/extensions/auto-mode/transcript.ts +236 -0
- package/extensions/auto-mode/types.ts +210 -0
- package/extensions/auto-mode/utils.ts +54 -0
- package/extensions/auto-mode.ts +27 -0
- package/package.json +61 -0
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
constants,
|
|
4
|
+
lstatSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
basename,
|
|
10
|
+
dirname,
|
|
11
|
+
isAbsolute,
|
|
12
|
+
join,
|
|
13
|
+
normalize,
|
|
14
|
+
relative,
|
|
15
|
+
resolve,
|
|
16
|
+
} from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { HOME, PATH_BEARING_TOOLS, PROFILE_FILES } from "./constants.ts";
|
|
19
|
+
|
|
20
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
21
|
+
|
|
22
|
+
/** Convert Git Bash, MSYS, Cygwin, and WSL drive paths for Windows APIs. */
|
|
23
|
+
function normalizeWindowsShellPath(path: string): string {
|
|
24
|
+
if (
|
|
25
|
+
process.platform !== "win32" ||
|
|
26
|
+
!path.startsWith("/") ||
|
|
27
|
+
path.startsWith("//") ||
|
|
28
|
+
path.includes("\\")
|
|
29
|
+
) {
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
33
|
+
if (!match) return path;
|
|
34
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
35
|
+
return `${match[1]?.toUpperCase()}:\\${suffix ?? ""}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Mirror Pi's path normalization options. */
|
|
39
|
+
function normalizeInputPath(
|
|
40
|
+
value: string,
|
|
41
|
+
options: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean } = {},
|
|
42
|
+
): string {
|
|
43
|
+
let normalized = options.normalizeUnicodeSpaces
|
|
44
|
+
? value.replace(UNICODE_SPACES, " ")
|
|
45
|
+
: value;
|
|
46
|
+
if (options.stripAtPrefix && normalized.startsWith("@")) {
|
|
47
|
+
normalized = normalized.slice(1);
|
|
48
|
+
}
|
|
49
|
+
normalized = normalizeWindowsShellPath(normalized);
|
|
50
|
+
if (normalized === "~") return HOME;
|
|
51
|
+
if (
|
|
52
|
+
normalized.startsWith("~/") ||
|
|
53
|
+
(process.platform === "win32" && normalized.startsWith("~\\"))
|
|
54
|
+
) {
|
|
55
|
+
return join(HOME, normalized.slice(2));
|
|
56
|
+
}
|
|
57
|
+
if (/^file:\/\//.test(normalized)) return fileURLToPath(normalized);
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveInputPath(
|
|
62
|
+
cwd: string,
|
|
63
|
+
value: unknown,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
if (typeof value !== "string") return undefined;
|
|
66
|
+
const normalized = normalizeInputPath(value, {
|
|
67
|
+
normalizeUnicodeSpaces: true,
|
|
68
|
+
stripAtPrefix: true,
|
|
69
|
+
});
|
|
70
|
+
return isAbsolute(normalized)
|
|
71
|
+
? resolve(normalized)
|
|
72
|
+
: resolve(normalizeInputPath(cwd), normalized);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function existingReadVariant(path: string): string {
|
|
76
|
+
const candidates = [
|
|
77
|
+
path,
|
|
78
|
+
path.replace(/ (AM|PM)\./gi, "\u202F$1."),
|
|
79
|
+
path.normalize("NFD"),
|
|
80
|
+
path.replace(/'/g, "\u2019"),
|
|
81
|
+
path.normalize("NFD").replace(/'/g, "\u2019"),
|
|
82
|
+
];
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
accessSync(candidate, constants.F_OK);
|
|
86
|
+
return candidate;
|
|
87
|
+
} catch {
|
|
88
|
+
// Try the next Pi-compatible read fallback.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Resolve the path that the named Pi file tool will operate on. */
|
|
95
|
+
export function resolveToolInputPath(
|
|
96
|
+
toolName: string,
|
|
97
|
+
cwd: string,
|
|
98
|
+
value: unknown,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
const resolved = resolveInputPath(cwd, value);
|
|
101
|
+
if (!resolved || toolName !== "read") return resolved;
|
|
102
|
+
return existingReadVariant(resolved);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The effective target path of a file tool, including Pi's `.` defaults. */
|
|
106
|
+
export function extractInputPath(
|
|
107
|
+
toolName: string,
|
|
108
|
+
input: Record<string, unknown>,
|
|
109
|
+
): string | undefined {
|
|
110
|
+
if (!PATH_BEARING_TOOLS.has(toolName)) return undefined;
|
|
111
|
+
const value = input.path;
|
|
112
|
+
if (typeof value === "string" && value !== "") return value;
|
|
113
|
+
if (toolName === "grep" || toolName === "find" || toolName === "ls") {
|
|
114
|
+
return ".";
|
|
115
|
+
}
|
|
116
|
+
return typeof value === "string" ? value : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Expand a leading `~`, `$HOME`, or `${HOME}` in a path-denial pattern. */
|
|
120
|
+
export function expandHomePattern(pattern: string): string {
|
|
121
|
+
const home = HOME.replace(/\\/g, "/");
|
|
122
|
+
if (pattern === "~" || pattern === "$HOME" || pattern === "${HOME}") {
|
|
123
|
+
return home;
|
|
124
|
+
}
|
|
125
|
+
if (pattern.startsWith("~/")) return `${home}/${pattern.slice(2)}`;
|
|
126
|
+
if (pattern.startsWith("$HOME/")) return `${home}/${pattern.slice(6)}`;
|
|
127
|
+
if (pattern.startsWith("${HOME}/")) return `${home}/${pattern.slice(8)}`;
|
|
128
|
+
return pattern;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function normalizePathForMatch(path: string, cwd: string): string {
|
|
132
|
+
const normalized = normalize(path);
|
|
133
|
+
const rel = relative(cwd, normalized);
|
|
134
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : normalized;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isInside(child: string, parent: string): boolean {
|
|
138
|
+
const rel = relative(parent, child);
|
|
139
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Resolve symlinks through the nearest existing ancestor of a path. */
|
|
143
|
+
export function resolvePathForPolicy(path: string): string | undefined {
|
|
144
|
+
return resolvePathForPolicyInner(resolve(path), new Set<string>());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolvePathForPolicyInner(
|
|
148
|
+
path: string,
|
|
149
|
+
visitedSymlinks: Set<string>,
|
|
150
|
+
): string | undefined {
|
|
151
|
+
let current = path;
|
|
152
|
+
const missingSegments: string[] = [];
|
|
153
|
+
|
|
154
|
+
while (true) {
|
|
155
|
+
try {
|
|
156
|
+
return resolve(realpathSync(current), ...missingSegments);
|
|
157
|
+
} catch {
|
|
158
|
+
try {
|
|
159
|
+
const stat = lstatSync(current);
|
|
160
|
+
if (!stat.isSymbolicLink() || visitedSymlinks.has(current)) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
visitedSymlinks.add(current);
|
|
164
|
+
const target = resolve(dirname(current), readlinkSync(current));
|
|
165
|
+
return resolvePathForPolicyInner(
|
|
166
|
+
resolve(target, ...missingSegments),
|
|
167
|
+
visitedSymlinks,
|
|
168
|
+
);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const code = error && typeof error === "object" && "code" in error
|
|
171
|
+
? String(error.code)
|
|
172
|
+
: undefined;
|
|
173
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return undefined;
|
|
174
|
+
const parent = dirname(current);
|
|
175
|
+
if (parent === current) return undefined;
|
|
176
|
+
missingSegments.unshift(basename(current));
|
|
177
|
+
current = parent;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeProtectedPathForMatch(value: string): string {
|
|
184
|
+
return value
|
|
185
|
+
.replace(/\\/g, "/")
|
|
186
|
+
.normalize("NFC")
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.normalize("NFC");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function matchesProtectedPath(
|
|
192
|
+
relativePath: string,
|
|
193
|
+
protectedPaths: string[],
|
|
194
|
+
): boolean {
|
|
195
|
+
const normalizedPath = normalizeProtectedPathForMatch(relativePath);
|
|
196
|
+
return protectedPaths.some((pattern) => {
|
|
197
|
+
const normalizedPattern = normalizeProtectedPathForMatch(pattern);
|
|
198
|
+
return normalizedPath === normalizedPattern ||
|
|
199
|
+
normalizedPath.startsWith(`${normalizedPattern}/`);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isProtectedPath(
|
|
204
|
+
path: string,
|
|
205
|
+
cwd: string,
|
|
206
|
+
protectedPaths: string[],
|
|
207
|
+
): boolean {
|
|
208
|
+
// Resolve through the nearest existing ancestor so symlinked directories are
|
|
209
|
+
// respected even when the final write target does not exist yet.
|
|
210
|
+
const resolved = resolvePathForPolicy(path) ?? path;
|
|
211
|
+
const resolvedCwd = resolvePathForPolicy(cwd) ?? cwd;
|
|
212
|
+
|
|
213
|
+
// For paths inside the project: use relative path for matching.
|
|
214
|
+
if (isInside(resolved, resolvedCwd)) {
|
|
215
|
+
return matchesProtectedPath(
|
|
216
|
+
relative(resolvedCwd, resolved),
|
|
217
|
+
protectedPaths,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// For paths outside the project: check every path component suffix.
|
|
222
|
+
// This catches writes like ../other-project/.git/config even when cwd
|
|
223
|
+
// doesn't contain the target.
|
|
224
|
+
const normalizedResolved = resolved.replace(/\\/g, "/");
|
|
225
|
+
const segments = normalizedResolved.split("/").filter(Boolean);
|
|
226
|
+
for (let i = 0; i < segments.length; i++) {
|
|
227
|
+
if (matchesProtectedPath(segments.slice(i).join("/"), protectedPaths)) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
235
|
+
const policyPath = resolvePathForPolicy(path) ?? resolve(path);
|
|
236
|
+
const policyCwd = resolvePathForPolicy(cwd) ?? resolve(cwd);
|
|
237
|
+
const normalized = normalizeProtectedPathForMatch(policyPath);
|
|
238
|
+
const file = basename(normalized);
|
|
239
|
+
const piAgentRoot = normalizeProtectedPathForMatch(
|
|
240
|
+
resolve(HOME, ".pi/agent"),
|
|
241
|
+
);
|
|
242
|
+
const globalExtensions = `${piAgentRoot}/extensions`;
|
|
243
|
+
const globalSettings = `${piAgentRoot}/settings`;
|
|
244
|
+
if (
|
|
245
|
+
normalized === `${piAgentRoot}/settings.json` ||
|
|
246
|
+
normalized === globalExtensions ||
|
|
247
|
+
normalized.startsWith(`${globalExtensions}/`) ||
|
|
248
|
+
normalized === globalSettings ||
|
|
249
|
+
normalized.startsWith(`${globalSettings}/`)
|
|
250
|
+
) {
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
if (
|
|
254
|
+
normalized.endsWith("/.pi/auto-mode.json") ||
|
|
255
|
+
normalized.endsWith("/auto-mode.json")
|
|
256
|
+
) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
if (normalized.includes("/.pi/extensions/") && file.includes("auto")) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (normalized.includes("/.pi/") && file.startsWith("automode")) return true;
|
|
263
|
+
// Installed package copies stay protected. The piAgentRoot extension prefix
|
|
264
|
+
// above already covers the global install location, and a dev checkout of
|
|
265
|
+
// the extension itself must stay editable, so the package name only denies
|
|
266
|
+
// node_modules vendoring.
|
|
267
|
+
if (
|
|
268
|
+
normalized.includes("/node_modules/pi-automode/") ||
|
|
269
|
+
normalized.includes("/node_modules/@czottmann/pi-automode/") ||
|
|
270
|
+
normalized.includes("/node_modules/@ibartel74/pi-automode-ext/") ||
|
|
271
|
+
(isInside(policyPath, policyCwd) && file.includes("auto-mode"))
|
|
272
|
+
) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function shellPathTokenToPath(
|
|
279
|
+
token: string,
|
|
280
|
+
cwd: string,
|
|
281
|
+
shellText = token,
|
|
282
|
+
): string | undefined {
|
|
283
|
+
let value = token.trim();
|
|
284
|
+
if (!value || value === "-" || value.startsWith("&")) return undefined;
|
|
285
|
+
value = value
|
|
286
|
+
.replace(/^\$HOME(?=\/|$)/, HOME)
|
|
287
|
+
.replace(/^\$\{HOME\}(?=\/|$)/, HOME);
|
|
288
|
+
if (shellText === "~") value = HOME;
|
|
289
|
+
else if (shellText.startsWith("~/")) value = resolve(HOME, value.slice(2));
|
|
290
|
+
return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function isProfileOrAuthorizedKeysPath(
|
|
294
|
+
path: string,
|
|
295
|
+
): string | undefined {
|
|
296
|
+
if (PROFILE_FILES.has(path)) {
|
|
297
|
+
return "shell profile modification is hard-denied";
|
|
298
|
+
}
|
|
299
|
+
if (path === resolve(HOME, ".ssh/authorized_keys")) {
|
|
300
|
+
return "SSH authorized_keys modification is hard-denied";
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|