@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.
@@ -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
- type ShellSegment = {
12
- text: string;
13
- words: string[];
14
- redirectTargets: string[];
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
- function tokenizeShellSegment(text: string): string[] {
67
- const tokens: string[] = [];
68
- let current = "";
69
- let quote: "'" | '"' | "`" | undefined;
70
- let escaped = false;
26
+ export type RmInvocation = {
27
+ recursive: boolean;
28
+ operands: Array<{ value: string; text: string; tildeExpansion: boolean }>;
29
+ };
71
30
 
72
- for (let i = 0; i < text.length; i += 1) {
73
- const char = text[i] ?? "";
74
- if (escaped) {
75
- current += char;
76
- escaped = false;
77
- continue;
78
- }
79
- if (char === "\\" && quote !== "'") {
80
- escaped = true;
81
- continue;
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 (/\s/.test(char)) {
93
- if (current) tokens.push(current);
94
- current = "";
43
+ if (!optionsEnded && value !== "-" && value.startsWith("-")) {
44
+ if (isRecursiveRmArg(value)) recursive = true;
95
45
  continue;
96
46
  }
97
- if (char === ">" || char === "<") {
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
- function parseShell(command: string): ShellSegment[] {
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 commandName(words: string[]): string | undefined {
143
- return words.find((word) => !/^\w+=/.test(word));
53
+ function isUnresolvedUserHomeToken(
54
+ shellText: string,
55
+ tildeExpansion: boolean,
56
+ ): boolean {
57
+ return tildeExpansion && shellText !== "~" && !shellText.startsWith("~/");
144
58
  }
145
59
 
146
- function commandArgs(words: string[]): string[] {
147
- const index = words.findIndex((word) => !/^\w+=/.test(word));
148
- return index >= 0 ? words.slice(index + 1) : [];
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 isRecursiveRmArg(arg: string): boolean {
152
- return (
153
- arg === "--recursive" ||
154
- /^-[A-Za-z]*r[A-Za-z]*f?[A-Za-z]*$/.test(arg) ||
155
- /^-[A-Za-z]*f[A-Za-z]*r[A-Za-z]*$/.test(arg)
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.startsWith(`${home}/`)) return false;
111
+ if (matchesPathRoot(path, home) && path.length > home.length) return false;
185
112
  return (
186
113
  path === "/" ||
187
- path === home ||
188
- systemRoots.some((root) => path === root || path.startsWith(`${root}/`))
114
+ matchesPathRoot(path, home) ||
115
+ systemRoots.some((root) => matchesPathRoot(path, root))
189
116
  );
190
117
  }
191
118
 
192
119
  function segmentHardDeny(
193
- segment: ShellSegment,
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 name = commandName(segment.words);
143
+ const command = segment.effectiveCommand;
144
+ const name = command.name;
217
145
  if (!name) return undefined;
218
- const args = commandArgs(segment.words);
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" && args.some(isRecursiveRmArg)) {
274
- for (const arg of args.filter((arg) => !arg.startsWith("-"))) {
275
- const path = shellPathTokenToPath(arg, cwd);
276
- if (path && isRootHomeOrSystemPath(path, HOME)) {
277
- return "irreversible deletion of home/root/system paths is hard-denied";
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
- if (root && isRootHomeOrSystemPath(root, HOME) && root !== HOME) {
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.text,
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 a small shell lexer instead of only regexes. It is not a full
336
- * POSIX shell implementation, but it handles quotes, redirects, pipes, `&&`, and
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
- for (const segment of parseShell(command)) {
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.replace(/\\/g, "/");
195
+ const normalizedPath = normalizeProtectedPathForMatch(relativePath);
188
196
  return protectedPaths.some((pattern) => {
189
- const normalizedPattern = pattern.replace(/\\/g, "/");
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 normalized = path.replace(/\\/g, "/");
228
- const file = basename(normalized).toLowerCase();
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(path, cwd) && file.includes("auto-mode"))
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 (value.startsWith("~/")) value = resolve(HOME, value.slice(2));
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