@czottmann/pi-automode 1.11.0 → 1.13.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 +39 -0
- package/README.md +82 -114
- package/docs/GLOSSARY.md +18 -14
- 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 +201 -107
- package/docs/configuration.md +171 -0
- package/docs/defaults.md +55 -14
- package/docs/diagnostics.md +90 -0
- package/docs/observability-logging.md +61 -26
- package/examples/automode.local.json +5 -0
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +172 -18
- package/extensions/auto-mode/config.ts +307 -32
- package/extensions/auto-mode/constants.ts +7 -1
- package/extensions/auto-mode/extension.ts +393 -44
- package/extensions/auto-mode/hard-deny.ts +103 -148
- package/extensions/auto-mode/log.ts +60 -5
- package/extensions/auto-mode/paths.ts +124 -14
- package/extensions/auto-mode/permissions.ts +542 -30
- package/extensions/auto-mode/state.ts +1 -0
- package/extensions/auto-mode/types.ts +11 -0
- package/extensions/auto-mode/utils.ts +9 -1
- package/extensions/auto-mode.ts +1 -0
- package/package.json +12 -2
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
1
2
|
import { resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
analyzeBash,
|
|
5
|
+
type BashAnalysis,
|
|
6
|
+
type BashCommandAnalysis,
|
|
7
|
+
type EffectiveCommand,
|
|
8
|
+
} from "./bash.ts";
|
|
2
9
|
import { HOME } from "./constants.ts";
|
|
3
10
|
import {
|
|
4
11
|
isProfileOrAuthorizedKeysPath,
|
|
@@ -8,152 +15,66 @@ import {
|
|
|
8
15
|
shellPathTokenToPath,
|
|
9
16
|
} from "./paths.ts";
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
function splitShellSegments(command: string): string[] {
|
|
18
|
-
const segments: string[] = [];
|
|
19
|
-
let current = "";
|
|
20
|
-
let quote: "'" | '"' | "`" | undefined;
|
|
21
|
-
let escaped = false;
|
|
22
|
-
|
|
23
|
-
for (let i = 0; i < command.length; i += 1) {
|
|
24
|
-
const char = command[i] ?? "";
|
|
25
|
-
const next = command[i + 1] ?? "";
|
|
26
|
-
if (escaped) {
|
|
27
|
-
current += char;
|
|
28
|
-
escaped = false;
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (char === "\\" && quote !== "'") {
|
|
32
|
-
current += char;
|
|
33
|
-
escaped = true;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
if (quote) {
|
|
37
|
-
current += char;
|
|
38
|
-
if (char === quote) quote = undefined;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (char === "'" || char === '"' || char === "`") {
|
|
42
|
-
quote = char;
|
|
43
|
-
current += char;
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (
|
|
47
|
-
char === ";" ||
|
|
48
|
-
char === "\n" ||
|
|
49
|
-
char === "|" ||
|
|
50
|
-
(char === "&" && next === "&") ||
|
|
51
|
-
(char === "|" && next === "|")
|
|
52
|
-
) {
|
|
53
|
-
if (current.trim()) segments.push(current.trim());
|
|
54
|
-
current = "";
|
|
55
|
-
if ((char === "&" && next === "&") || (char === "|" && next === "|")) {
|
|
56
|
-
i += 1;
|
|
57
|
-
}
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
current += char;
|
|
61
|
-
}
|
|
62
|
-
if (current.trim()) segments.push(current.trim());
|
|
63
|
-
return segments;
|
|
18
|
+
function isRecursiveRmArg(arg: string): boolean {
|
|
19
|
+
return (
|
|
20
|
+
(arg.length > 2 && arg.startsWith("--") && "--recursive".startsWith(arg)) ||
|
|
21
|
+
/^-[A-Za-z]*r[A-Za-z]*f?[A-Za-z]*$/i.test(arg) ||
|
|
22
|
+
/^-[A-Za-z]*f[A-Za-z]*r[A-Za-z]*$/i.test(arg)
|
|
23
|
+
);
|
|
64
24
|
}
|
|
65
25
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
let escaped = false;
|
|
26
|
+
export type RmInvocation = {
|
|
27
|
+
recursive: boolean;
|
|
28
|
+
operands: Array<{ value: string; text: string; tildeExpansion: boolean }>;
|
|
29
|
+
};
|
|
71
30
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
if (quote) {
|
|
84
|
-
if (char === quote) quote = undefined;
|
|
85
|
-
else current += char;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (char === "'" || char === '"' || char === "`") {
|
|
89
|
-
quote = char;
|
|
31
|
+
export function parseRmInvocation(command: EffectiveCommand): RmInvocation {
|
|
32
|
+
let recursive = false;
|
|
33
|
+
let optionsEnded = false;
|
|
34
|
+
const operands: RmInvocation["operands"] = [];
|
|
35
|
+
|
|
36
|
+
for (const [index, value] of command.args.entries()) {
|
|
37
|
+
const text = command.argTexts[index] ?? value;
|
|
38
|
+
const tildeExpansion = command.argTildeExpansions[index] ?? false;
|
|
39
|
+
if (!optionsEnded && value === "--") {
|
|
40
|
+
optionsEnded = true;
|
|
90
41
|
continue;
|
|
91
42
|
}
|
|
92
|
-
if (
|
|
93
|
-
if (
|
|
94
|
-
current = "";
|
|
43
|
+
if (!optionsEnded && value !== "-" && value.startsWith("-")) {
|
|
44
|
+
if (isRecursiveRmArg(value)) recursive = true;
|
|
95
45
|
continue;
|
|
96
46
|
}
|
|
97
|
-
|
|
98
|
-
let op = char;
|
|
99
|
-
if (/^\d+$/.test(current)) {
|
|
100
|
-
op = current + char;
|
|
101
|
-
} else if (current) {
|
|
102
|
-
tokens.push(current);
|
|
103
|
-
}
|
|
104
|
-
if (text[i + 1] === ">" || text[i + 1] === "&") {
|
|
105
|
-
op += text[i + 1];
|
|
106
|
-
i += 1;
|
|
107
|
-
}
|
|
108
|
-
tokens.push(op);
|
|
109
|
-
current = "";
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
current += char;
|
|
47
|
+
operands.push({ value, text, tildeExpansion });
|
|
113
48
|
}
|
|
114
|
-
if (current) tokens.push(current);
|
|
115
|
-
return tokens;
|
|
116
|
-
}
|
|
117
49
|
|
|
118
|
-
|
|
119
|
-
return splitShellSegments(command).map((text) => {
|
|
120
|
-
const tokens = tokenizeShellSegment(text);
|
|
121
|
-
const words: string[] = [];
|
|
122
|
-
const redirectTargets: string[] = [];
|
|
123
|
-
for (let i = 0; i < tokens.length; i += 1) {
|
|
124
|
-
const token = tokens[i] ?? "";
|
|
125
|
-
if (/^(?:\d?>|\d?>>|>|>>|&>|<)$/.test(token)) {
|
|
126
|
-
const target = tokens[i + 1];
|
|
127
|
-
if (target) redirectTargets.push(target);
|
|
128
|
-
i += 1;
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const attachedRedirect = token.match(/^(?:\d?>|\d?>>|>|>>|&>)(.+)$/);
|
|
132
|
-
if (attachedRedirect?.[1]) {
|
|
133
|
-
redirectTargets.push(attachedRedirect[1]);
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
words.push(token);
|
|
137
|
-
}
|
|
138
|
-
return { text, words, redirectTargets };
|
|
139
|
-
});
|
|
50
|
+
return { recursive, operands };
|
|
140
51
|
}
|
|
141
52
|
|
|
142
|
-
function
|
|
143
|
-
|
|
53
|
+
function isUnresolvedUserHomeToken(
|
|
54
|
+
shellText: string,
|
|
55
|
+
tildeExpansion: boolean,
|
|
56
|
+
): boolean {
|
|
57
|
+
return tildeExpansion && shellText !== "~" && !shellText.startsWith("~/");
|
|
144
58
|
}
|
|
145
59
|
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
60
|
+
function isSameExistingPath(left: string, right: string): boolean {
|
|
61
|
+
try {
|
|
62
|
+
const leftStat = statSync(left);
|
|
63
|
+
const rightStat = statSync(right);
|
|
64
|
+
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
149
68
|
}
|
|
150
69
|
|
|
151
|
-
function
|
|
152
|
-
return
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
70
|
+
function matchesPathRoot(path: string, root: string): boolean {
|
|
71
|
+
if (path === root || path.startsWith(`${root}/`)) return true;
|
|
72
|
+
const lowerPath = path.toLowerCase();
|
|
73
|
+
const lowerRoot = root.toLowerCase();
|
|
74
|
+
if (lowerPath !== lowerRoot && !lowerPath.startsWith(`${lowerRoot}/`)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return isSameExistingPath(path.slice(0, root.length), root);
|
|
157
78
|
}
|
|
158
79
|
|
|
159
80
|
/**
|
|
@@ -173,24 +94,30 @@ export function isRootHomeOrSystemPath(path: string, home: string): boolean {
|
|
|
173
94
|
"/boot",
|
|
174
95
|
"/dev",
|
|
175
96
|
"/etc",
|
|
97
|
+
"/home",
|
|
176
98
|
"/lib",
|
|
177
99
|
"/lib64",
|
|
100
|
+
"/Library",
|
|
178
101
|
"/private",
|
|
102
|
+
"/proc",
|
|
103
|
+
"/root",
|
|
104
|
+
"/run",
|
|
179
105
|
"/sbin",
|
|
180
106
|
"/sys",
|
|
107
|
+
"/System",
|
|
181
108
|
"/usr",
|
|
182
109
|
"/var",
|
|
183
110
|
];
|
|
184
|
-
if (path.
|
|
111
|
+
if (matchesPathRoot(path, home) && path.length > home.length) return false;
|
|
185
112
|
return (
|
|
186
113
|
path === "/" ||
|
|
187
|
-
path
|
|
188
|
-
systemRoots.some((root) => path
|
|
114
|
+
matchesPathRoot(path, home) ||
|
|
115
|
+
systemRoots.some((root) => matchesPathRoot(path, root))
|
|
189
116
|
);
|
|
190
117
|
}
|
|
191
118
|
|
|
192
119
|
function segmentHardDeny(
|
|
193
|
-
segment:
|
|
120
|
+
segment: BashCommandAnalysis,
|
|
194
121
|
cwd: string,
|
|
195
122
|
): string | undefined {
|
|
196
123
|
for (const target of segment.redirectTargets) {
|
|
@@ -213,9 +140,10 @@ function segmentHardDeny(
|
|
|
213
140
|
}
|
|
214
141
|
}
|
|
215
142
|
|
|
216
|
-
const
|
|
143
|
+
const command = segment.effectiveCommand;
|
|
144
|
+
const name = command.name;
|
|
217
145
|
if (!name) return undefined;
|
|
218
|
-
const args =
|
|
146
|
+
const args = command.args;
|
|
219
147
|
const lowerArgs = args.map((arg) => arg.toLowerCase());
|
|
220
148
|
|
|
221
149
|
if (
|
|
@@ -270,18 +198,32 @@ function segmentHardDeny(
|
|
|
270
198
|
return "platform security weakening is hard-denied";
|
|
271
199
|
}
|
|
272
200
|
|
|
273
|
-
if (name === "rm"
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
201
|
+
if (name === "rm") {
|
|
202
|
+
const rm = parseRmInvocation(command);
|
|
203
|
+
if (rm.recursive) {
|
|
204
|
+
for (const { value: arg, text: shellText, tildeExpansion } of rm.operands) {
|
|
205
|
+
if (isUnresolvedUserHomeToken(shellText, tildeExpansion)) {
|
|
206
|
+
return "irreversible deletion of a user-home expansion is hard-denied";
|
|
207
|
+
}
|
|
208
|
+
const path = shellPathTokenToPath(arg, cwd, shellText);
|
|
209
|
+
const policyPath = path ? (resolvePathForPolicy(path) ?? path) : undefined;
|
|
210
|
+
const policyHome = resolvePathForPolicy(HOME) ?? HOME;
|
|
211
|
+
if (policyPath && isRootHomeOrSystemPath(policyPath, policyHome)) {
|
|
212
|
+
return "irreversible deletion of home/root/system paths is hard-denied";
|
|
213
|
+
}
|
|
278
214
|
}
|
|
279
215
|
}
|
|
280
216
|
}
|
|
281
217
|
|
|
282
218
|
if (name === "find" && lowerArgs.includes("-delete")) {
|
|
283
219
|
const root = shellPathTokenToPath(args[0] ?? "", cwd);
|
|
284
|
-
|
|
220
|
+
const policyRoot = root ? (resolvePathForPolicy(root) ?? root) : undefined;
|
|
221
|
+
const policyHome = resolvePathForPolicy(HOME) ?? HOME;
|
|
222
|
+
if (
|
|
223
|
+
policyRoot &&
|
|
224
|
+
isRootHomeOrSystemPath(policyRoot, policyHome) &&
|
|
225
|
+
policyRoot !== policyHome
|
|
226
|
+
) {
|
|
285
227
|
return "system-wide delete is hard-denied";
|
|
286
228
|
}
|
|
287
229
|
}
|
|
@@ -320,7 +262,7 @@ function segmentHardDeny(
|
|
|
320
262
|
"sed",
|
|
321
263
|
].includes(name) &&
|
|
322
264
|
/\.pi\/automode|\.pi\/extensions|pi-automode|auto-mode\.json/i.test(
|
|
323
|
-
segment.
|
|
265
|
+
segment.raw,
|
|
324
266
|
)
|
|
325
267
|
) {
|
|
326
268
|
return "auto-mode or permission safety-control modification is hard-denied";
|
|
@@ -332,14 +274,14 @@ function segmentHardDeny(
|
|
|
332
274
|
/**
|
|
333
275
|
* Deterministic deny checks for actions too risky to delegate to the classifier.
|
|
334
276
|
*
|
|
335
|
-
* Bash checks use
|
|
336
|
-
*
|
|
337
|
-
* `;` well enough to avoid the common "safe prefix hides risky suffix" bypass.
|
|
277
|
+
* Bash checks use the shared unbash AST analysis. The hook passes one analysis
|
|
278
|
+
* through every enforcement stage so nested commands are not reparsed.
|
|
338
279
|
*/
|
|
339
280
|
export function deterministicHardDeny(
|
|
340
281
|
toolName: string,
|
|
341
282
|
input: Record<string, unknown>,
|
|
342
283
|
cwd: string,
|
|
284
|
+
bashAnalysis?: BashAnalysis,
|
|
343
285
|
): string | undefined {
|
|
344
286
|
if (toolName === "write" || toolName === "edit") {
|
|
345
287
|
const path = resolveInputPath(cwd, input.path);
|
|
@@ -355,7 +297,20 @@ export function deterministicHardDeny(
|
|
|
355
297
|
|
|
356
298
|
if (toolName !== "bash") return undefined;
|
|
357
299
|
const command = typeof input.command === "string" ? input.command : "";
|
|
358
|
-
|
|
300
|
+
const analysis = bashAnalysis ?? analyzeBash(command);
|
|
301
|
+
if (analysis.errors.length > 0) {
|
|
302
|
+
return `Bash input could not be parsed safely: ${analysis.errors[0]?.message ?? "unknown parser error"}`;
|
|
303
|
+
}
|
|
304
|
+
for (const target of analysis.redirectTargets) {
|
|
305
|
+
const path = shellPathTokenToPath(target, cwd);
|
|
306
|
+
if (!path) continue;
|
|
307
|
+
const profileReason = isProfileOrAuthorizedKeysPath(path);
|
|
308
|
+
if (profileReason) return profileReason;
|
|
309
|
+
if (isSafetyControlPath(path, cwd)) {
|
|
310
|
+
return "auto-mode or permission safety-control modification is hard-denied";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const segment of analysis.commands) {
|
|
359
314
|
const reason = segmentHardDeny(segment, cwd);
|
|
360
315
|
if (reason) return reason;
|
|
361
316
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
5
|
import type {
|
|
5
6
|
ClassifierIo,
|
|
6
7
|
ClassifierIoAttempt,
|
|
@@ -65,9 +66,32 @@ export type LoggerOptions = {
|
|
|
65
66
|
classifierIo: boolean;
|
|
66
67
|
sessionFile?: string;
|
|
67
68
|
sessionDir: string;
|
|
69
|
+
/** Effective cwd for an in-memory session. */
|
|
70
|
+
sessionCwd?: string;
|
|
68
71
|
sessionId: string;
|
|
72
|
+
/** Test/embedder override. Runtime uses ~/.pi/agent/extensions/pi-automode/logs. */
|
|
73
|
+
logRoot?: string;
|
|
74
|
+
/** Test clock used for the UTC date partition. */
|
|
75
|
+
now?: Date;
|
|
69
76
|
};
|
|
70
77
|
|
|
78
|
+
export const DEFAULT_AUTOMODE_LOG_ROOT = join(
|
|
79
|
+
homedir(),
|
|
80
|
+
".pi/agent/extensions/pi-automode/logs",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const VALID_SESSION_ID =
|
|
84
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
85
|
+
|
|
86
|
+
function safeLogSessionId(sessionId: string): string {
|
|
87
|
+
if (VALID_SESSION_ID.test(sessionId)) return sessionId;
|
|
88
|
+
const digest = createHash("sha256")
|
|
89
|
+
.update(sessionId)
|
|
90
|
+
.digest("hex")
|
|
91
|
+
.slice(0, 16);
|
|
92
|
+
return `invalid-${digest}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
/** Short id linking a classifier entry to its decision entry in the same file. */
|
|
72
96
|
export function newDecisionId(): string {
|
|
73
97
|
return randomBytes(4).toString("hex");
|
|
@@ -76,19 +100,43 @@ export function newDecisionId(): string {
|
|
|
76
100
|
/**
|
|
77
101
|
* Derive the log file path from the current session: the session file's
|
|
78
102
|
* directory with `-pi-automode` inserted before the extension. Falls back to
|
|
79
|
-
*
|
|
103
|
+
* an absolute session directory when one is available. In-memory sessions use
|
|
104
|
+
* an application-owned, project- and date-partitioned directory instead of a
|
|
105
|
+
* relative path resolved against the launching process cwd.
|
|
80
106
|
*/
|
|
81
107
|
export function resolveLogPath(
|
|
82
108
|
sessionFile: string | undefined,
|
|
83
109
|
sessionDir: string,
|
|
84
110
|
sessionId: string,
|
|
111
|
+
sessionCwd = process.cwd(),
|
|
112
|
+
logRoot = DEFAULT_AUTOMODE_LOG_ROOT,
|
|
113
|
+
now = new Date(),
|
|
85
114
|
): string {
|
|
86
115
|
if (sessionFile) {
|
|
87
116
|
const ext = extname(sessionFile);
|
|
88
117
|
const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
|
|
89
118
|
return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
|
|
90
119
|
}
|
|
91
|
-
|
|
120
|
+
|
|
121
|
+
const logFile = `${safeLogSessionId(sessionId)}-pi-automode.jsonl`;
|
|
122
|
+
if (isAbsolute(sessionDir)) {
|
|
123
|
+
return join(sessionDir, logFile);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resolvedCwd = resolve(sessionCwd);
|
|
127
|
+
const projectDir = `--${
|
|
128
|
+
resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")
|
|
129
|
+
}--`;
|
|
130
|
+
const dateDir = now.toISOString().slice(0, 10);
|
|
131
|
+
const resolvedLogRoot = isAbsolute(logRoot)
|
|
132
|
+
? logRoot
|
|
133
|
+
: DEFAULT_AUTOMODE_LOG_ROOT;
|
|
134
|
+
return join(
|
|
135
|
+
resolvedLogRoot,
|
|
136
|
+
projectDir,
|
|
137
|
+
dateDir,
|
|
138
|
+
logFile,
|
|
139
|
+
);
|
|
92
140
|
}
|
|
93
141
|
|
|
94
142
|
/** Append one JSON object as a line. Failures are swallowed: logging must
|
|
@@ -105,7 +153,14 @@ function appendJsonl(path: string, entry: unknown): void {
|
|
|
105
153
|
/** Build a logger bound to one session's log path. No-ops when disabled. */
|
|
106
154
|
export function createLogger(opts: LoggerOptions): Logger {
|
|
107
155
|
const { enabled, classifierIo } = opts;
|
|
108
|
-
const path = resolveLogPath(
|
|
156
|
+
const path = resolveLogPath(
|
|
157
|
+
opts.sessionFile,
|
|
158
|
+
opts.sessionDir,
|
|
159
|
+
opts.sessionId,
|
|
160
|
+
opts.sessionCwd,
|
|
161
|
+
opts.logRoot,
|
|
162
|
+
opts.now,
|
|
163
|
+
);
|
|
109
164
|
return {
|
|
110
165
|
enabled,
|
|
111
166
|
classifierIo,
|
|
@@ -1,35 +1,119 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
constants,
|
|
4
|
+
lstatSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
} from "node:fs";
|
|
2
8
|
import {
|
|
3
9
|
basename,
|
|
4
10
|
dirname,
|
|
5
11
|
isAbsolute,
|
|
12
|
+
join,
|
|
6
13
|
normalize,
|
|
7
14
|
relative,
|
|
8
15
|
resolve,
|
|
9
16
|
} from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
10
18
|
import { HOME, PATH_BEARING_TOOLS, PROFILE_FILES } from "./constants.ts";
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
|
14
59
|
}
|
|
15
60
|
|
|
16
61
|
export function resolveInputPath(
|
|
17
62
|
cwd: string,
|
|
18
63
|
value: unknown,
|
|
19
64
|
): string | undefined {
|
|
20
|
-
if (typeof value !== "string"
|
|
21
|
-
const
|
|
22
|
-
|
|
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);
|
|
23
73
|
}
|
|
24
74
|
|
|
25
|
-
|
|
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. */
|
|
26
106
|
export function extractInputPath(
|
|
27
107
|
toolName: string,
|
|
28
108
|
input: Record<string, unknown>,
|
|
29
109
|
): string | undefined {
|
|
30
110
|
if (!PATH_BEARING_TOOLS.has(toolName)) return undefined;
|
|
31
111
|
const value = input.path;
|
|
32
|
-
|
|
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;
|
|
33
117
|
}
|
|
34
118
|
|
|
35
119
|
/** Expand a leading `~`, `$HOME`, or `${HOME}` in a path-denial pattern. */
|
|
@@ -96,13 +180,21 @@ function resolvePathForPolicyInner(
|
|
|
96
180
|
}
|
|
97
181
|
}
|
|
98
182
|
|
|
183
|
+
function normalizeProtectedPathForMatch(value: string): string {
|
|
184
|
+
return value
|
|
185
|
+
.replace(/\\/g, "/")
|
|
186
|
+
.normalize("NFC")
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.normalize("NFC");
|
|
189
|
+
}
|
|
190
|
+
|
|
99
191
|
export function matchesProtectedPath(
|
|
100
192
|
relativePath: string,
|
|
101
193
|
protectedPaths: string[],
|
|
102
194
|
): boolean {
|
|
103
|
-
const normalizedPath = relativePath
|
|
195
|
+
const normalizedPath = normalizeProtectedPathForMatch(relativePath);
|
|
104
196
|
return protectedPaths.some((pattern) => {
|
|
105
|
-
const normalizedPattern = pattern
|
|
197
|
+
const normalizedPattern = normalizeProtectedPathForMatch(pattern);
|
|
106
198
|
return normalizedPath === normalizedPattern ||
|
|
107
199
|
normalizedPath.startsWith(`${normalizedPattern}/`);
|
|
108
200
|
});
|
|
@@ -140,8 +232,24 @@ export function isProtectedPath(
|
|
|
140
232
|
}
|
|
141
233
|
|
|
142
234
|
export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
143
|
-
const
|
|
144
|
-
const
|
|
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
|
+
}
|
|
145
253
|
if (
|
|
146
254
|
normalized.endsWith("/.pi/auto-mode.json") ||
|
|
147
255
|
normalized.endsWith("/auto-mode.json")
|
|
@@ -154,7 +262,7 @@ export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
|
154
262
|
if (normalized.includes("/.pi/") && file.startsWith("automode")) return true;
|
|
155
263
|
if (
|
|
156
264
|
normalized.includes("/pi-automode/") ||
|
|
157
|
-
(isInside(
|
|
265
|
+
(isInside(policyPath, policyCwd) && file.includes("auto-mode"))
|
|
158
266
|
) {
|
|
159
267
|
return true;
|
|
160
268
|
}
|
|
@@ -164,13 +272,15 @@ export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
|
164
272
|
export function shellPathTokenToPath(
|
|
165
273
|
token: string,
|
|
166
274
|
cwd: string,
|
|
275
|
+
shellText = token,
|
|
167
276
|
): string | undefined {
|
|
168
277
|
let value = token.trim();
|
|
169
278
|
if (!value || value === "-" || value.startsWith("&")) return undefined;
|
|
170
279
|
value = value
|
|
171
280
|
.replace(/^\$HOME(?=\/|$)/, HOME)
|
|
172
281
|
.replace(/^\$\{HOME\}(?=\/|$)/, HOME);
|
|
173
|
-
if (
|
|
282
|
+
if (shellText === "~") value = HOME;
|
|
283
|
+
else if (shellText.startsWith("~/")) value = resolve(HOME, value.slice(2));
|
|
174
284
|
return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
|
|
175
285
|
}
|
|
176
286
|
|