@czottmann/pi-automode 1.12.0 → 1.14.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,12 @@
1
+ import { statSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
1
3
  import { resolve } from "node:path";
4
+ import {
5
+ analyzeBash,
6
+ type BashAnalysis,
7
+ type BashCommandAnalysis,
8
+ type EffectiveCommand,
9
+ } from "./bash.ts";
2
10
  import { HOME } from "./constants.ts";
3
11
  import {
4
12
  isProfileOrAuthorizedKeysPath,
@@ -8,157 +16,177 @@ import {
8
16
  shellPathTokenToPath,
9
17
  } from "./paths.ts";
10
18
 
11
- type ShellSegment = {
12
- text: string;
13
- words: string[];
14
- redirectTargets: string[];
19
+ function isRecursiveRmArg(arg: string): boolean {
20
+ return (
21
+ (arg.length > 2 && arg.startsWith("--") && "--recursive".startsWith(arg)) ||
22
+ /^-[A-Za-z]*r[A-Za-z]*f?[A-Za-z]*$/i.test(arg) ||
23
+ /^-[A-Za-z]*f[A-Za-z]*r[A-Za-z]*$/i.test(arg)
24
+ );
25
+ }
26
+
27
+ export type RmInvocation = {
28
+ recursive: boolean;
29
+ operands: Array<{ value: string; text: string; tildeExpansion: boolean }>;
15
30
  };
16
31
 
17
- function splitShellSegments(command: string): string[] {
18
- const segments: string[] = [];
19
- let current = "";
20
- let quote: "'" | '"' | "`" | undefined;
21
- let escaped = false;
32
+ export function parseRmInvocation(command: EffectiveCommand): RmInvocation {
33
+ let recursive = false;
34
+ let optionsEnded = false;
35
+ const operands: RmInvocation["operands"] = [];
22
36
 
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;
37
+ for (const [index, value] of command.args.entries()) {
38
+ const text = command.argTexts[index] ?? value;
39
+ const tildeExpansion = command.argTildeExpansions[index] ?? false;
40
+ if (!optionsEnded && value === "--") {
41
+ optionsEnded = true;
39
42
  continue;
40
43
  }
41
- if (char === "'" || char === '"' || char === "`") {
42
- quote = char;
43
- current += char;
44
+ if (!optionsEnded && value !== "-" && value.startsWith("-")) {
45
+ if (isRecursiveRmArg(value)) recursive = true;
44
46
  continue;
45
47
  }
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;
48
+ operands.push({ value, text, tildeExpansion });
61
49
  }
62
- if (current.trim()) segments.push(current.trim());
63
- return segments;
50
+
51
+ return { recursive, operands };
64
52
  }
65
53
 
66
- function tokenizeShellSegment(text: string): string[] {
67
- const tokens: string[] = [];
68
- let current = "";
69
- let quote: "'" | '"' | "`" | undefined;
70
- let escaped = false;
54
+ function isUnresolvedUserHomeToken(
55
+ shellText: string,
56
+ tildeExpansion: boolean,
57
+ ): boolean {
58
+ return tildeExpansion && shellText !== "~" && !shellText.startsWith("~/");
59
+ }
71
60
 
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;
90
- continue;
91
- }
92
- if (/\s/.test(char)) {
93
- if (current) tokens.push(current);
94
- current = "";
95
- continue;
96
- }
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;
61
+ function isSameExistingPath(left: string, right: string): boolean {
62
+ try {
63
+ const leftStat = statSync(left);
64
+ const rightStat = statSync(right);
65
+ return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
66
+ } catch {
67
+ return false;
113
68
  }
114
- if (current) tokens.push(current);
115
- return tokens;
116
69
  }
117
70
 
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
- });
71
+ function matchesPathRoot(path: string, root: string): boolean {
72
+ if (path === root || path.startsWith(`${root}/`)) return true;
73
+ const lowerPath = path.toLowerCase();
74
+ const lowerRoot = root.toLowerCase();
75
+ if (lowerPath !== lowerRoot && !lowerPath.startsWith(`${lowerRoot}/`)) {
76
+ return false;
77
+ }
78
+ return isSameExistingPath(path.slice(0, root.length), root);
140
79
  }
141
80
 
142
- function commandName(words: string[]): string | undefined {
143
- return words.find((word) => !/^\w+=/.test(word));
81
+ /**
82
+ * Top-level directories whose deletion or wholesale modification is a
83
+ * system-wide event. Shared between candidate validation and path
84
+ * classification; do not inline copies.
85
+ */
86
+ const SYSTEM_ROOTS: ReadonlyArray<string> = [
87
+ "/bin",
88
+ "/boot",
89
+ "/dev",
90
+ "/etc",
91
+ "/home",
92
+ "/lib",
93
+ "/lib64",
94
+ "/Library",
95
+ "/private",
96
+ "/proc",
97
+ "/root",
98
+ "/run",
99
+ "/sbin",
100
+ "/sys",
101
+ "/System",
102
+ "/usr",
103
+ "/var",
104
+ ];
105
+
106
+ /**
107
+ * Normalize a proposed temp-root value into a comparable absolute path.
108
+ * Returns undefined for values that cannot denote a subdirectory: the
109
+ * empty string, `/`, and slash-only artifacts.
110
+ */
111
+ function normalizeRootCandidate(value: string): string | undefined {
112
+ const stripped = value.replace(/\/+$/, "");
113
+ // An empty stripped value must not fall through to `resolve()`, which
114
+ // would silently turn `/` into the process working directory.
115
+ if (!stripped) return undefined;
116
+ const normalized = resolve(stripped);
117
+ if (normalized === "/") return undefined;
118
+ return normalized;
144
119
  }
145
120
 
146
- function commandArgs(words: string[]): string[] {
147
- const index = words.findIndex((word) => !/^\w+=/.test(word));
148
- return index >= 0 ? words.slice(index + 1) : [];
121
+ /**
122
+ * True when a candidate temp root would weaken the deterministic deny tiers:
123
+ * it aliases (exact, case-folded, or dev/inode) `HOME`, `/`, or any system
124
+ * root, or it is a proper ancestor of the canonical home directory.
125
+ *
126
+ * Dev/inode identity covers symlinked spellings of existing directories;
127
+ * string comparison alone handles candidates that do not exist yet. See
128
+ * issue #31: without these guards, `TMPDIR=/` reduced every absolute path
129
+ * below an empty-string prefix match, and `TMPDIR=/private` made
130
+ * `/private/etc/**` disposable.
131
+ */
132
+ function conflictsWithProtectedRoots(candidate: string, home: string): boolean {
133
+ if (candidate === "/") return true;
134
+ for (const protectedRoot of ["/", home, ...SYSTEM_ROOTS]) {
135
+ const canonical = resolve(protectedRoot);
136
+ if (
137
+ candidate === canonical ||
138
+ candidate.toLowerCase() === canonical.toLowerCase()
139
+ ) {
140
+ return true;
141
+ }
142
+ if (isSameExistingPath(candidate, canonical)) return true;
143
+ }
144
+ const homeCanonical = resolvePathForPolicy(home) ?? resolve(home);
145
+ return homeCanonical.toLowerCase().startsWith(`${candidate.toLowerCase()}/`);
149
146
  }
150
147
 
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
- );
148
+ let cachedTempRoots: ReadonlyArray<string> | undefined;
149
+ let cachedTmpdirValue: string | undefined;
150
+
151
+ /**
152
+ * Validated launcher-declared temp-dir roots whose subtrees are treated as
153
+ * disposable by `isRootHomeOrSystemPath`: the platform tmpdir, plus `/tmp` on
154
+ * macOS where it exists independently of `os.tmpdir()`. Each root appears in
155
+ * canonical and resolved spelling because callers pass symlink-resolved
156
+ * policy paths (`/tmp` → `/private/tmp` on macOS) and unresolved fallbacks
157
+ * alike. Values from `os.tmpdir()` are not trusted blindly; see
158
+ * `conflictsWithProtectedRoots`. The roots themselves are never exempt:
159
+ * deleting one is a system-wide delete.
160
+ *
161
+ * The memoization keys on the effective `os.tmpdir()` return value so tests
162
+ * can mutate `TMPDIR`, `TMP`, or `TEMP` between calls.
163
+ */
164
+ export function tempRootCandidates(): ReadonlyArray<string> {
165
+ const currentTmpdir = tmpdir();
166
+ if (cachedTempRoots && cachedTmpdirValue === currentTmpdir) {
167
+ return cachedTempRoots;
168
+ }
169
+ cachedTmpdirValue = currentTmpdir;
170
+ cachedTempRoots = (() => {
171
+ const roots = new Set<string>();
172
+ const consider = (value: string) => {
173
+ const candidate = normalizeRootCandidate(value);
174
+ if (!candidate || conflictsWithProtectedRoots(candidate, HOME)) return;
175
+ roots.add(candidate);
176
+ const resolved = resolvePathForPolicy(candidate);
177
+ if (resolved) roots.add(resolved);
178
+ };
179
+ consider(currentTmpdir);
180
+ if (process.platform === "darwin") consider("/tmp");
181
+ return [...roots];
182
+ })();
183
+ return cachedTempRoots;
157
184
  }
158
185
 
159
186
  /**
160
187
  * True for `/`, the user's home root, or a top-level system root such as
161
- * `/etc`, `/usr`, or `/var`. Excludes the home *subtree*.
188
+ * `/etc`, `/usr`, or `/var`. Excludes the home *subtree* and the subtrees of
189
+ * platform temp directories (`os.tmpdir()` and `/tmp` on macOS).
162
190
  *
163
191
  * On some distros (e.g. Fedora Silverblue) HOME lives under `/var`, which is
164
192
  * in `systemRoots`. Without the subtree exemption, `path.startsWith("/var/")`
@@ -166,31 +194,41 @@ function isRecursiveRmArg(arg: string): boolean {
166
194
  * `rm -rf ~/...`. HOME itself is still matched below, so `rm -rf ~` stays
167
195
  * blocked. `home` is a parameter so this can be unit-tested with a synthetic
168
196
  * `/var/home/...` value.
197
+ *
198
+ * The temp exemption mirrors the home one and covers cleanup of directories
199
+ * created with `mktemp`, `os.tmpdir()`, or plain `/tmp` paths. On macOS these
200
+ * resolve into `/private/tmp` or `/private/var/folders`, which used to match
201
+ * the `/private` system root and hard-deny every temp cleanup. Deleting a
202
+ * temp root itself still returns true; `tempRoots` is injectable for tests.
203
+ *
204
+ * Protection order matters (issue #31): exact `/`, the exact home root, and
205
+ * system roots win over every exemption. Injected `tempRoots` values are
206
+ * validated per call so hostile or malformed candidates cannot weaken the
207
+ * deterministic tiers.
169
208
  */
170
- export function isRootHomeOrSystemPath(path: string, home: string): boolean {
171
- const systemRoots = [
172
- "/bin",
173
- "/boot",
174
- "/dev",
175
- "/etc",
176
- "/lib",
177
- "/lib64",
178
- "/private",
179
- "/sbin",
180
- "/sys",
181
- "/usr",
182
- "/var",
183
- ];
184
- if (path.startsWith(`${home}/`)) return false;
209
+ export function isRootHomeOrSystemPath(
210
+ path: string,
211
+ home: string,
212
+ tempRoots: ReadonlyArray<string> = tempRootCandidates(),
213
+ ): boolean {
214
+ if (path === "/") return true;
215
+ if (path === home || isSameExistingPath(path, home)) return true;
216
+ if (matchesPathRoot(path, home) && path.length > home.length) return false;
217
+ for (const root of tempRoots) {
218
+ const candidate = normalizeRootCandidate(root);
219
+ if (!candidate || conflictsWithProtectedRoots(candidate, home)) continue;
220
+ if (!matchesPathRoot(path, candidate)) continue;
221
+ // Subtree: disposable. Exact match: the temp root stays protected.
222
+ return path.length > candidate.length ? false : true;
223
+ }
185
224
  return (
186
- path === "/" ||
187
- path === home ||
188
- systemRoots.some((root) => path === root || path.startsWith(`${root}/`))
225
+ matchesPathRoot(path, home) ||
226
+ SYSTEM_ROOTS.some((root) => matchesPathRoot(path, root))
189
227
  );
190
228
  }
191
229
 
192
230
  function segmentHardDeny(
193
- segment: ShellSegment,
231
+ segment: BashCommandAnalysis,
194
232
  cwd: string,
195
233
  ): string | undefined {
196
234
  for (const target of segment.redirectTargets) {
@@ -213,9 +251,10 @@ function segmentHardDeny(
213
251
  }
214
252
  }
215
253
 
216
- const name = commandName(segment.words);
254
+ const command = segment.effectiveCommand;
255
+ const name = command.name;
217
256
  if (!name) return undefined;
218
- const args = commandArgs(segment.words);
257
+ const args = command.args;
219
258
  const lowerArgs = args.map((arg) => arg.toLowerCase());
220
259
 
221
260
  if (
@@ -270,18 +309,32 @@ function segmentHardDeny(
270
309
  return "platform security weakening is hard-denied";
271
310
  }
272
311
 
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";
312
+ if (name === "rm") {
313
+ const rm = parseRmInvocation(command);
314
+ if (rm.recursive) {
315
+ for (const { value: arg, text: shellText, tildeExpansion } of rm.operands) {
316
+ if (isUnresolvedUserHomeToken(shellText, tildeExpansion)) {
317
+ return "irreversible deletion of a user-home expansion is hard-denied";
318
+ }
319
+ const path = shellPathTokenToPath(arg, cwd, shellText);
320
+ const policyPath = path ? (resolvePathForPolicy(path) ?? path) : undefined;
321
+ const policyHome = resolvePathForPolicy(HOME) ?? HOME;
322
+ if (policyPath && isRootHomeOrSystemPath(policyPath, policyHome)) {
323
+ return "irreversible deletion of home/root/system paths is hard-denied";
324
+ }
278
325
  }
279
326
  }
280
327
  }
281
328
 
282
329
  if (name === "find" && lowerArgs.includes("-delete")) {
283
330
  const root = shellPathTokenToPath(args[0] ?? "", cwd);
284
- if (root && isRootHomeOrSystemPath(root, HOME) && root !== HOME) {
331
+ const policyRoot = root ? (resolvePathForPolicy(root) ?? root) : undefined;
332
+ const policyHome = resolvePathForPolicy(HOME) ?? HOME;
333
+ if (
334
+ policyRoot &&
335
+ isRootHomeOrSystemPath(policyRoot, policyHome) &&
336
+ policyRoot !== policyHome
337
+ ) {
285
338
  return "system-wide delete is hard-denied";
286
339
  }
287
340
  }
@@ -320,7 +373,7 @@ function segmentHardDeny(
320
373
  "sed",
321
374
  ].includes(name) &&
322
375
  /\.pi\/automode|\.pi\/extensions|pi-automode|auto-mode\.json/i.test(
323
- segment.text,
376
+ segment.raw,
324
377
  )
325
378
  ) {
326
379
  return "auto-mode or permission safety-control modification is hard-denied";
@@ -332,14 +385,14 @@ function segmentHardDeny(
332
385
  /**
333
386
  * Deterministic deny checks for actions too risky to delegate to the classifier.
334
387
  *
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.
388
+ * Bash checks use the shared unbash AST analysis. The hook passes one analysis
389
+ * through every enforcement stage so nested commands are not reparsed.
338
390
  */
339
391
  export function deterministicHardDeny(
340
392
  toolName: string,
341
393
  input: Record<string, unknown>,
342
394
  cwd: string,
395
+ bashAnalysis?: BashAnalysis,
343
396
  ): string | undefined {
344
397
  if (toolName === "write" || toolName === "edit") {
345
398
  const path = resolveInputPath(cwd, input.path);
@@ -355,7 +408,20 @@ export function deterministicHardDeny(
355
408
 
356
409
  if (toolName !== "bash") return undefined;
357
410
  const command = typeof input.command === "string" ? input.command : "";
358
- for (const segment of parseShell(command)) {
411
+ const analysis = bashAnalysis ?? analyzeBash(command);
412
+ if (analysis.errors.length > 0) {
413
+ return `Bash input could not be parsed safely: ${analysis.errors[0]?.message ?? "unknown parser error"}`;
414
+ }
415
+ for (const target of analysis.redirectTargets) {
416
+ const path = shellPathTokenToPath(target, cwd);
417
+ if (!path) continue;
418
+ const profileReason = isProfileOrAuthorizedKeysPath(path);
419
+ if (profileReason) return profileReason;
420
+ if (isSafetyControlPath(path, cwd)) {
421
+ return "auto-mode or permission safety-control modification is hard-denied";
422
+ }
423
+ }
424
+ for (const segment of analysis.commands) {
359
425
  const reason = segmentHardDeny(segment, cwd);
360
426
  if (reason) return reason;
361
427
  }
@@ -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