@czottmann/pi-automode 1.12.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 +15 -0
- package/README.md +5 -3
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +2 -0
- package/docs/adr/ADR-002-global-config-in-extension-data-directory.md +60 -0
- package/docs/adr/INDEX.md +1 -0
- package/docs/automode-classifier-flow.md +4 -4
- package/docs/configuration.md +20 -4
- package/docs/observability-logging.md +1 -1
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/config.ts +181 -3
- package/extensions/auto-mode/constants.ts +4 -1
- package/extensions/auto-mode/extension.ts +143 -36
- package/extensions/auto-mode/hard-deny.ts +103 -148
- package/extensions/auto-mode/paths.ts +32 -6
- package/extensions/auto-mode/permissions.ts +344 -22
- package/extensions/auto-mode.ts +1 -0
- package/package.json +4 -1
|
@@ -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
|
}
|
|
@@ -180,13 +180,21 @@ function resolvePathForPolicyInner(
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
function normalizeProtectedPathForMatch(value: string): string {
|
|
184
|
+
return value
|
|
185
|
+
.replace(/\\/g, "/")
|
|
186
|
+
.normalize("NFC")
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.normalize("NFC");
|
|
189
|
+
}
|
|
190
|
+
|
|
183
191
|
export function matchesProtectedPath(
|
|
184
192
|
relativePath: string,
|
|
185
193
|
protectedPaths: string[],
|
|
186
194
|
): boolean {
|
|
187
|
-
const normalizedPath = relativePath
|
|
195
|
+
const normalizedPath = normalizeProtectedPathForMatch(relativePath);
|
|
188
196
|
return protectedPaths.some((pattern) => {
|
|
189
|
-
const normalizedPattern = pattern
|
|
197
|
+
const normalizedPattern = normalizeProtectedPathForMatch(pattern);
|
|
190
198
|
return normalizedPath === normalizedPattern ||
|
|
191
199
|
normalizedPath.startsWith(`${normalizedPattern}/`);
|
|
192
200
|
});
|
|
@@ -224,8 +232,24 @@ export function isProtectedPath(
|
|
|
224
232
|
}
|
|
225
233
|
|
|
226
234
|
export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
227
|
-
const
|
|
228
|
-
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
|
+
}
|
|
229
253
|
if (
|
|
230
254
|
normalized.endsWith("/.pi/auto-mode.json") ||
|
|
231
255
|
normalized.endsWith("/auto-mode.json")
|
|
@@ -238,7 +262,7 @@ export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
|
238
262
|
if (normalized.includes("/.pi/") && file.startsWith("automode")) return true;
|
|
239
263
|
if (
|
|
240
264
|
normalized.includes("/pi-automode/") ||
|
|
241
|
-
(isInside(
|
|
265
|
+
(isInside(policyPath, policyCwd) && file.includes("auto-mode"))
|
|
242
266
|
) {
|
|
243
267
|
return true;
|
|
244
268
|
}
|
|
@@ -248,13 +272,15 @@ export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
|
248
272
|
export function shellPathTokenToPath(
|
|
249
273
|
token: string,
|
|
250
274
|
cwd: string,
|
|
275
|
+
shellText = token,
|
|
251
276
|
): string | undefined {
|
|
252
277
|
let value = token.trim();
|
|
253
278
|
if (!value || value === "-" || value.startsWith("&")) return undefined;
|
|
254
279
|
value = value
|
|
255
280
|
.replace(/^\$HOME(?=\/|$)/, HOME)
|
|
256
281
|
.replace(/^\$\{HOME\}(?=\/|$)/, HOME);
|
|
257
|
-
if (
|
|
282
|
+
if (shellText === "~") value = HOME;
|
|
283
|
+
else if (shellText.startsWith("~/")) value = resolve(HOME, value.slice(2));
|
|
258
284
|
return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
|
|
259
285
|
}
|
|
260
286
|
|