@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.
@@ -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
  }
@@ -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 { basename, dirname, extname, join } from "node:path";
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
- * `<sessionDir>/<sessionId>-pi-automode.jsonl` when no session file is set.
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
- return join(sessionDir, `${sessionId}-pi-automode.jsonl`);
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(opts.sessionFile, opts.sessionDir, opts.sessionId);
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 { lstatSync, readlinkSync, realpathSync } from "node:fs";
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
- function stripLeadingAt(value: string): string {
13
- return value.startsWith("@") ? value.slice(1) : value;
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" || value.trim() === "") return undefined;
21
- const raw = stripLeadingAt(value.trim());
22
- return isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
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
- /** The target path a file tool operates on, from `input.path` (or undefined). */
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
- return typeof value === "string" && value.trim() !== "" ? value : undefined;
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.replace(/\\/g, "/");
195
+ const normalizedPath = normalizeProtectedPathForMatch(relativePath);
104
196
  return protectedPaths.some((pattern) => {
105
- const normalizedPattern = pattern.replace(/\\/g, "/");
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 normalized = path.replace(/\\/g, "/");
144
- 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
+ }
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(path, cwd) && file.includes("auto-mode"))
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 (value.startsWith("~/")) value = resolve(HOME, value.slice(2));
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