pi-safety-guards 0.1.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.
@@ -0,0 +1,590 @@
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import {
5
+ analyzeShellCommand,
6
+ isShellAssignment,
7
+ splitRecognizedShellOption,
8
+ type ShellCommandInvocation,
9
+ type ShellWord as Word,
10
+ type ShellWrapperInvocation,
11
+ } from "./shell-command-utils";
12
+
13
+ const SINGLE_OPTION_VALUE_COUNT = 1;
14
+ const NAME_AND_VALUE_OPTION_COUNT = 2;
15
+ const CHANGE_DIRECTORY_COMMAND_NAME = "cd";
16
+ const JQ_COMMAND_NAME = "jq";
17
+ const DATA_ONLY_COMMANDS = new Set(["echo", "printf"]);
18
+ const GREP_COMMANDS = new Set(["egrep", "fgrep", "grep", "rg", "ripgrep"]);
19
+ const SED_COMMANDS = new Set(["gsed", "sed"]);
20
+ const AWK_COMMANDS = new Set(["awk", "gawk", "mawk", "nawk"]);
21
+ const FILE_TEST_OPERATORS = new Set([
22
+ "-a", "-b", "-c", "-d", "-e", "-f", "-g", "-h", "-k", "-L", "-N", "-O",
23
+ "-p", "-r", "-s", "-S", "-t", "-u", "-w", "-x",
24
+ ]);
25
+ const FILE_COMPARISON_OPERATORS = new Set(["-ef", "-nt", "-ot"]);
26
+ const INLINE_PATH_OPTIONS = new Set([
27
+ "--cache-dir",
28
+ "--chdir",
29
+ "--config",
30
+ "--cwd",
31
+ "--directory",
32
+ "--file",
33
+ "--git-dir",
34
+ "--ignore-file",
35
+ "--input",
36
+ "--output",
37
+ "--prefix",
38
+ "--project",
39
+ "--root",
40
+ "--source",
41
+ "--target",
42
+ "--temp-dir",
43
+ "--tmpdir",
44
+ "--work-tree",
45
+ ]);
46
+
47
+ interface PatternPathOptions {
48
+ args: readonly Word[];
49
+ auxiliaryPathOptions: ReadonlySet<string>;
50
+ explicitPatternOptions: ReadonlySet<string>;
51
+ patternPathOptions: ReadonlySet<string>;
52
+ valueOptionWidths: Readonly<Record<string, number>>;
53
+ }
54
+
55
+ interface AddedDirectoryState {
56
+ dirs?: Array<{ absolutePath?: unknown }>;
57
+ }
58
+
59
+ const ADD_DIRECTORY_STATE_TYPE = "add-dir:state";
60
+ const SESSION_SQUASH_TYPE = "session-squash";
61
+
62
+ export interface BashPathViolation {
63
+ inputPath: string;
64
+ resolvedPath: string;
65
+ }
66
+
67
+ const EMPTY_OPTIONS = new Set<string>();
68
+ const WRAPPER_PATH_OPTIONS: Readonly<Record<string, ReadonlySet<string>>> = {
69
+ sudo: new Set(["-D", "--chdir", "-R", "--chroot"]),
70
+ env: new Set(["-C", "--chdir"]),
71
+ };
72
+
73
+ const GREP_PATTERN_PATH_OPTIONS = new Set(["-f", "--file"]);
74
+ const GREP_AUXILIARY_PATH_OPTIONS = new Set(["--ignore-file"]);
75
+ const GREP_EXPLICIT_PATTERN_OPTIONS = new Set(["-e", "--regexp"]);
76
+ const GREP_VALUE_OPTION_WIDTHS: Readonly<Record<string, number>> = {
77
+ "-A": SINGLE_OPTION_VALUE_COUNT,
78
+ "--after-context": SINGLE_OPTION_VALUE_COUNT,
79
+ "-B": SINGLE_OPTION_VALUE_COUNT,
80
+ "--before-context": SINGLE_OPTION_VALUE_COUNT,
81
+ "-C": SINGLE_OPTION_VALUE_COUNT,
82
+ "--context": SINGLE_OPTION_VALUE_COUNT,
83
+ "--colors": SINGLE_OPTION_VALUE_COUNT,
84
+ "--context-separator": SINGLE_OPTION_VALUE_COUNT,
85
+ "--encoding": SINGLE_OPTION_VALUE_COUNT,
86
+ "--engine": SINGLE_OPTION_VALUE_COUNT,
87
+ "-e": SINGLE_OPTION_VALUE_COUNT,
88
+ "--exclude": SINGLE_OPTION_VALUE_COUNT,
89
+ "--exclude-dir": SINGLE_OPTION_VALUE_COUNT,
90
+ "--field-context-separator": SINGLE_OPTION_VALUE_COUNT,
91
+ "--field-match-separator": SINGLE_OPTION_VALUE_COUNT,
92
+ "-g": SINGLE_OPTION_VALUE_COUNT,
93
+ "--glob": SINGLE_OPTION_VALUE_COUNT,
94
+ "--hostname-bin": SINGLE_OPTION_VALUE_COUNT,
95
+ "--hyperlink-format": SINGLE_OPTION_VALUE_COUNT,
96
+ "--iglob": SINGLE_OPTION_VALUE_COUNT,
97
+ "--include": SINGLE_OPTION_VALUE_COUNT,
98
+ "--label": SINGLE_OPTION_VALUE_COUNT,
99
+ "-m": SINGLE_OPTION_VALUE_COUNT,
100
+ "--max-count": SINGLE_OPTION_VALUE_COUNT,
101
+ "--path-separator": SINGLE_OPTION_VALUE_COUNT,
102
+ "--pre": SINGLE_OPTION_VALUE_COUNT,
103
+ "--pre-glob": SINGLE_OPTION_VALUE_COUNT,
104
+ "--regexp": SINGLE_OPTION_VALUE_COUNT,
105
+ "--replace": SINGLE_OPTION_VALUE_COUNT,
106
+ "--sort": SINGLE_OPTION_VALUE_COUNT,
107
+ "--sortr": SINGLE_OPTION_VALUE_COUNT,
108
+ "-t": SINGLE_OPTION_VALUE_COUNT,
109
+ "--type": SINGLE_OPTION_VALUE_COUNT,
110
+ "--type-add": SINGLE_OPTION_VALUE_COUNT,
111
+ "--type-clear": SINGLE_OPTION_VALUE_COUNT,
112
+ };
113
+ const SED_PATTERN_PATH_OPTIONS = new Set(["-f", "--file"]);
114
+ const SED_EXPLICIT_PATTERN_OPTIONS = new Set(["-e", "--expression"]);
115
+ const SED_VALUE_OPTION_WIDTHS: Readonly<Record<string, number>> = {
116
+ "-e": SINGLE_OPTION_VALUE_COUNT,
117
+ "--expression": SINGLE_OPTION_VALUE_COUNT,
118
+ };
119
+ const AWK_PATTERN_PATH_OPTIONS = new Set(["-f", "--file"]);
120
+ const AWK_VALUE_OPTION_WIDTHS: Readonly<Record<string, number>> = {
121
+ "-F": SINGLE_OPTION_VALUE_COUNT,
122
+ "--field-separator": SINGLE_OPTION_VALUE_COUNT,
123
+ "-v": SINGLE_OPTION_VALUE_COUNT,
124
+ "--assign": SINGLE_OPTION_VALUE_COUNT,
125
+ };
126
+ const JQ_FILTER_FILE_OPTIONS = new Set(["-f", "--from-file"]);
127
+ const JQ_LIBRARY_PATH_OPTIONS = new Set(["-L"]);
128
+ const JQ_FILE_BINDING_OPTIONS = new Set(["--rawfile", "--slurpfile"]);
129
+ const JQ_VALUE_OPTION_WIDTHS: Readonly<Record<string, number>> = {
130
+ "--arg": NAME_AND_VALUE_OPTION_COUNT,
131
+ "--argjson": NAME_AND_VALUE_OPTION_COUNT,
132
+ };
133
+
134
+ /**
135
+ * 从当前会话恢复 add_directory 白名单。
136
+ *
137
+ * session_squash 会把当前分支切到较早的 user entry,再追加新的摘要消息;
138
+ * 因此压缩前的 add-dir:state 不再位于 active branch,但仍保留在 session entries
139
+ * 中。沿 summary details.sourceLeafId 回溯,才能得到压缩时实际生效的目录状态。
140
+ */
141
+ export function addedDirectoryPathsFromSession(
142
+ entries: readonly unknown[],
143
+ activeBranch: readonly unknown[],
144
+ ): string[] {
145
+ const byId = new Map<string, Record<string, unknown>>();
146
+ for (const entry of entries) {
147
+ if (isRecord(entry) && typeof entry.id === "string") byId.set(entry.id, entry);
148
+ }
149
+
150
+ return replayDirectoryState(activeBranch, byId, new Map(), new Set()).paths;
151
+ }
152
+
153
+ /** 找出 Bash 命令中显式引用、但不在允许目录内的本地路径。 */
154
+ export function findOutOfScopeBashPaths(
155
+ command: string,
156
+ cwd: string,
157
+ roots: readonly string[],
158
+ ): BashPathViolation[] {
159
+ const allowedRoots = canonicalRoots(roots.map((root) => {
160
+ const expanded = expandKnownPathPrefix(root, cwd);
161
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
162
+ }));
163
+ const referencedPaths = collectReferencedPaths(command);
164
+ const violations: BashPathViolation[] = [];
165
+ const seen = new Set<string>();
166
+
167
+ for (const inputPath of referencedPaths) {
168
+ const resolvedPath = resolveReferencedPath(inputPath, cwd);
169
+ if (!resolvedPath) continue;
170
+ if (allowedRoots.some((root) => isWithinRoot(resolvedPath, root))) continue;
171
+
172
+ const key = `${inputPath}\0${resolvedPath}`;
173
+ if (seen.has(key)) continue;
174
+ seen.add(key);
175
+ violations.push({ inputPath, resolvedPath });
176
+ }
177
+
178
+ return violations;
179
+ }
180
+
181
+ interface DirectoryState {
182
+ known: boolean;
183
+ paths: string[];
184
+ }
185
+
186
+ /** 按分支时序回放目录状态;squash checkpoint 会覆盖此前状态。 */
187
+ function replayDirectoryState(
188
+ branch: readonly unknown[],
189
+ byId: ReadonlyMap<string, Record<string, unknown>>,
190
+ memo: Map<string, DirectoryState | undefined>,
191
+ resolving: Set<string>,
192
+ ): DirectoryState {
193
+ let state: DirectoryState = { known: false, paths: [] };
194
+ for (const entry of branch) {
195
+ if (isAddDirectoryState(entry)) {
196
+ state = parseAddedDirectoryState(entry);
197
+ continue;
198
+ }
199
+ if (!isSessionSquashEntry(entry)) continue;
200
+
201
+ const source = sourceLeafId(entry);
202
+ const sourceState = source === undefined
203
+ ? undefined
204
+ : resolveDirectoryStateAtLeaf(source, byId, memo, resolving);
205
+ // squash 代表当时的有效状态;源记录缺失或循环时清空授权,不能恢复更早的旧授权。
206
+ state = sourceState?.known ? sourceState : { known: true, paths: [] };
207
+ }
208
+ return state;
209
+ }
210
+
211
+ function resolveDirectoryStateAtLeaf(
212
+ leafId: string,
213
+ byId: ReadonlyMap<string, Record<string, unknown>>,
214
+ memo: Map<string, DirectoryState | undefined>,
215
+ resolving: Set<string>,
216
+ ): DirectoryState | undefined {
217
+ if (memo.has(leafId)) return memo.get(leafId);
218
+ if (resolving.has(leafId)) return undefined;
219
+ const branch = ancestorEntries(leafId, byId);
220
+ if (branch.length === 0) return undefined;
221
+
222
+ resolving.add(leafId);
223
+ const state = replayDirectoryState(branch, byId, memo, resolving);
224
+ resolving.delete(leafId);
225
+ memo.set(leafId, state);
226
+ return state;
227
+ }
228
+
229
+ function parseAddedDirectoryState(entry: Record<string, unknown>): DirectoryState {
230
+ const data = isRecord(entry.data) ? entry.data as AddedDirectoryState : undefined;
231
+ const dirs = Array.isArray(data?.dirs) ? data.dirs : [];
232
+ return {
233
+ known: true,
234
+ paths: [...new Set(dirs
235
+ .map((dir) => isRecord(dir) ? dir.absolutePath : undefined)
236
+ .filter((value): value is string => typeof value === "string" && isAbsolute(value)))]
237
+ };
238
+ }
239
+
240
+ function isAddDirectoryState(entry: unknown): entry is Record<string, unknown> {
241
+ return isRecord(entry) && entry.type === "custom" && entry.customType === ADD_DIRECTORY_STATE_TYPE;
242
+ }
243
+
244
+ function isSessionSquashEntry(entry: unknown): entry is Record<string, unknown> {
245
+ return isRecord(entry) && entry.type === "custom_message" && entry.customType === SESSION_SQUASH_TYPE;
246
+ }
247
+
248
+ function sourceLeafId(entry: Record<string, unknown>): string | undefined {
249
+ const details = isRecord(entry.details) ? entry.details : undefined;
250
+ return typeof details?.sourceLeafId === "string" ? details.sourceLeafId : undefined;
251
+ }
252
+
253
+ function ancestorEntries(
254
+ leafId: string,
255
+ byId: ReadonlyMap<string, Record<string, unknown>>,
256
+ ): Record<string, unknown>[] {
257
+ const ancestors: Record<string, unknown>[] = [];
258
+ const visited = new Set<string>();
259
+ let currentId: string | null = leafId;
260
+ while (currentId && !visited.has(currentId)) {
261
+ visited.add(currentId);
262
+ const entry = byId.get(currentId);
263
+ if (!entry) break;
264
+ ancestors.push(entry);
265
+ currentId = typeof entry.parentId === "string" ? entry.parentId : null;
266
+ }
267
+ return ancestors.reverse();
268
+ }
269
+
270
+ /** 从共享 Shell 分析结果收集命令参数、wrapper、重定向和文件测试中的路径。 */
271
+ function collectReferencedPaths(command: string): string[] {
272
+ const analysis = analyzeShellCommand(command);
273
+ const references = [
274
+ ...analysis.wrappers.flatMap((wrapper) => wrapperPaths(wrapper)),
275
+ ...analysis.commands.flatMap((invocation) => commandPaths(invocation)),
276
+ ];
277
+
278
+ for (const node of analysis.nodes) {
279
+ if ((node.type === "For" || node.type === "Select") && Array.isArray(node.wordlist)) {
280
+ references.push(...node.wordlist.flatMap((word) => wordValueCandidates(word)));
281
+ }
282
+ if (node.type === "TestUnary" && FILE_TEST_OPERATORS.has(String(node.operator))) {
283
+ references.push(...wordValueCandidates(node.operand));
284
+ }
285
+ if (node.type === "TestBinary" && FILE_COMPARISON_OPERATORS.has(String(node.operator))) {
286
+ references.push(...wordValueCandidates(node.left), ...wordValueCandidates(node.right));
287
+ }
288
+ if (isFileRedirect(node)) references.push(...wordValueCandidates(node.target, true));
289
+ }
290
+
291
+ return references;
292
+ }
293
+
294
+ /** 按实际命令语义提取可执行文件和参数中的路径。 */
295
+ function commandPaths(invocation: ShellCommandInvocation): string[] {
296
+ const paths = executablePathCandidates(invocation.executable.value);
297
+ if (invocation.nestedSource !== undefined) return paths;
298
+ if (invocation.name === CHANGE_DIRECTORY_COMMAND_NAME) return [...paths, ...cdPaths(invocation.args)];
299
+ if (DATA_ONLY_COMMANDS.has(invocation.name)) return paths;
300
+ if (GREP_COMMANDS.has(invocation.name)) {
301
+ return [...paths, ...patternCommandPaths({
302
+ args: invocation.args,
303
+ auxiliaryPathOptions: GREP_AUXILIARY_PATH_OPTIONS,
304
+ explicitPatternOptions: GREP_EXPLICIT_PATTERN_OPTIONS,
305
+ patternPathOptions: GREP_PATTERN_PATH_OPTIONS,
306
+ valueOptionWidths: GREP_VALUE_OPTION_WIDTHS,
307
+ })];
308
+ }
309
+ if (SED_COMMANDS.has(invocation.name)) {
310
+ return [...paths, ...patternCommandPaths({
311
+ args: invocation.args,
312
+ auxiliaryPathOptions: EMPTY_OPTIONS,
313
+ explicitPatternOptions: SED_EXPLICIT_PATTERN_OPTIONS,
314
+ patternPathOptions: SED_PATTERN_PATH_OPTIONS,
315
+ valueOptionWidths: SED_VALUE_OPTION_WIDTHS,
316
+ })];
317
+ }
318
+ if (AWK_COMMANDS.has(invocation.name)) {
319
+ return [...paths, ...patternCommandPaths({
320
+ args: invocation.args,
321
+ auxiliaryPathOptions: EMPTY_OPTIONS,
322
+ explicitPatternOptions: EMPTY_OPTIONS,
323
+ patternPathOptions: AWK_PATTERN_PATH_OPTIONS,
324
+ valueOptionWidths: AWK_VALUE_OPTION_WIDTHS,
325
+ })];
326
+ }
327
+ if (invocation.name === JQ_COMMAND_NAME) return [...paths, ...jqPaths(invocation.args)];
328
+
329
+ return [...paths, ...invocation.args.flatMap((word) => pathCandidates(word.value))];
330
+ }
331
+
332
+ /** wrapper 的目录型选项和路径形式可执行文件同样受目录范围约束。 */
333
+ function wrapperPaths(wrapper: ShellWrapperInvocation): string[] {
334
+ const paths = executablePathCandidates(wrapper.executable.value);
335
+ const pathOptions = WRAPPER_PATH_OPTIONS[wrapper.name];
336
+ if (!pathOptions) return paths;
337
+
338
+ for (const option of wrapper.options) {
339
+ if (option.value !== undefined && pathOptions.has(option.name)) {
340
+ paths.push(...pathCandidates(option.value));
341
+ }
342
+ }
343
+ return paths;
344
+ }
345
+
346
+ /** 路径形式的可执行文件也接受同一目录策略,不隐式信任 PATH 或 skills。 */
347
+ function executablePathCandidates(value: string): string[] {
348
+ return value.includes("/") || value.includes("\\") ? [value] : [];
349
+ }
350
+
351
+ /** 解析 cd 的默认 HOME、cd - 和普通目录参数。 */
352
+ function cdPaths(args: readonly Word[]): string[] {
353
+ let optionsEnded = false;
354
+
355
+ for (const arg of args) {
356
+ const value = arg.value;
357
+ if (!optionsEnded && value === "--") {
358
+ optionsEnded = true;
359
+ continue;
360
+ }
361
+ if (!optionsEnded && value.startsWith("-") && value !== "-") continue;
362
+ if (value === "-") return process.env.OLDPWD ? [process.env.OLDPWD] : [];
363
+ return pathCandidates(value);
364
+ }
365
+
366
+ return [homedir()];
367
+ }
368
+
369
+ /** 对 grep/sed/awk 一类“首个位置参数是模式”的命令,仅保留真实文件参数。 */
370
+ function patternCommandPaths(options: PatternPathOptions): string[] {
371
+ const paths: string[] = [];
372
+ const recognizedOptions = new Set([
373
+ ...options.auxiliaryPathOptions,
374
+ ...options.explicitPatternOptions,
375
+ ...options.patternPathOptions,
376
+ ...Object.keys(options.valueOptionWidths),
377
+ ]);
378
+ let firstPositionalConsumed = false;
379
+ let optionsEnded = false;
380
+
381
+ for (let index = 0; index < options.args.length; index++) {
382
+ const value = options.args[index].value;
383
+ if (!optionsEnded && value === "--") {
384
+ optionsEnded = true;
385
+ continue;
386
+ }
387
+
388
+ if (!optionsEnded && value.startsWith("-") && value !== "-") {
389
+ const [optionName, inlineValue] = splitRecognizedShellOption(value, recognizedOptions);
390
+ const isPatternPath = options.patternPathOptions.has(optionName);
391
+ const isAuxiliaryPath = options.auxiliaryPathOptions.has(optionName);
392
+ if (isPatternPath || isAuxiliaryPath) {
393
+ const pathValue = inlineValue ?? options.args[++index]?.value;
394
+ if (pathValue) paths.push(...pathCandidates(pathValue));
395
+ if (isPatternPath) firstPositionalConsumed = true;
396
+ continue;
397
+ }
398
+
399
+ const valueWidth = options.valueOptionWidths[optionName] ?? 0;
400
+ if (!inlineValue) index += valueWidth;
401
+ if (options.explicitPatternOptions.has(optionName)) firstPositionalConsumed = true;
402
+ continue;
403
+ }
404
+
405
+ if (!firstPositionalConsumed) {
406
+ firstPositionalConsumed = true;
407
+ continue;
408
+ }
409
+ paths.push(...pathCandidates(value));
410
+ }
411
+
412
+ return paths;
413
+ }
414
+
415
+ /** 按 jq 参数语义区分 filter、模块目录、绑定文件和输入文件。 */
416
+ function jqPaths(args: readonly Word[]): string[] {
417
+ const paths: string[] = [];
418
+ const recognizedOptions = new Set([
419
+ ...JQ_FILTER_FILE_OPTIONS,
420
+ ...JQ_LIBRARY_PATH_OPTIONS,
421
+ ...JQ_FILE_BINDING_OPTIONS,
422
+ ...Object.keys(JQ_VALUE_OPTION_WIDTHS),
423
+ ]);
424
+ let filterProvided = false;
425
+ let optionsEnded = false;
426
+
427
+ for (let index = 0; index < args.length; index++) {
428
+ const value = args[index].value;
429
+ if (!optionsEnded && value === "--") {
430
+ optionsEnded = true;
431
+ continue;
432
+ }
433
+
434
+ if (!optionsEnded && value.startsWith("-") && value !== "-") {
435
+ const [optionName, inlineValue] = splitRecognizedShellOption(value, recognizedOptions);
436
+ if (JQ_FILTER_FILE_OPTIONS.has(optionName)) {
437
+ const pathValue = inlineValue ?? args[++index]?.value;
438
+ if (pathValue) paths.push(...pathCandidates(pathValue));
439
+ filterProvided = true;
440
+ continue;
441
+ }
442
+ if (JQ_LIBRARY_PATH_OPTIONS.has(optionName)) {
443
+ const pathValue = inlineValue ?? args[++index]?.value;
444
+ if (pathValue) paths.push(...pathCandidates(pathValue));
445
+ continue;
446
+ }
447
+ if (JQ_FILE_BINDING_OPTIONS.has(optionName)) {
448
+ const pathValue = inlineValue ?? args[index + NAME_AND_VALUE_OPTION_COUNT]?.value;
449
+ if (pathValue) paths.push(...pathCandidates(pathValue));
450
+ if (!inlineValue) index += NAME_AND_VALUE_OPTION_COUNT;
451
+ continue;
452
+ }
453
+
454
+ const valueWidth = JQ_VALUE_OPTION_WIDTHS[optionName] ?? 0;
455
+ if (!inlineValue) index += valueWidth;
456
+ continue;
457
+ }
458
+
459
+ if (!filterProvided) {
460
+ filterProvided = true;
461
+ continue;
462
+ }
463
+ paths.push(...pathCandidates(value));
464
+ }
465
+
466
+ return paths;
467
+ }
468
+
469
+ /** 将参数词转换为路径候选;普通非选项词也保留,以识别无斜杠的符号链接。 */
470
+ function pathCandidates(value: string): string[] {
471
+ if (!value || isShellAssignment(value)) return [];
472
+ if (looksLikeUrl(value) && !value.startsWith("file://")) return [];
473
+
474
+ if (!value.startsWith("-") || value === "-") return [value];
475
+
476
+ const [optionName, inlineValue] = splitOption(value);
477
+ if (inlineValue && INLINE_PATH_OPTIONS.has(optionName)) return [inlineValue];
478
+
479
+ const shortMatch = value.match(/^-[CIL](.+)$/);
480
+ return shortMatch ? [shortMatch[1]] : [];
481
+ }
482
+
483
+ /** 从未知 AST 值中读取 Word.value,并按需强制把重定向目标视为路径。 */
484
+ function wordValueCandidates(value: unknown, force = false): string[] {
485
+ if (!isRecord(value) || typeof value.value !== "string") return [];
486
+ return force ? [value.value] : pathCandidates(value.value);
487
+ }
488
+
489
+ /** 展开已知目录前缀并通过最近存在祖先解析符号链接。 */
490
+ function resolveReferencedPath(input: string, cwd: string): string | null {
491
+ let value = input;
492
+ if (value.startsWith("file://")) {
493
+ value = decodeURIComponent(new URL(value).pathname);
494
+ }
495
+
496
+ value = expandKnownPathPrefix(value, cwd);
497
+ if (!value || startsWithUnknownExpansion(value)) return null;
498
+
499
+ const absolutePath = isAbsolute(value) ? resolve(value) : resolve(cwd, value);
500
+ return canonicalizePotentialPath(absolutePath);
501
+ }
502
+
503
+ /** 规范化允许根目录并去重,避免符号链接别名绕过范围判断。 */
504
+ function canonicalRoots(paths: readonly string[]): string[] {
505
+ const roots = paths
506
+ .filter((value) => typeof value === "string" && value.length > 0)
507
+ .map((value) => canonicalizePotentialPath(resolve(value)));
508
+ return [...new Set(roots)];
509
+ }
510
+
511
+ /** 对不存在的目标向上寻找最近存在祖先,再 realpath 以阻断 symlink 越界。 */
512
+ function canonicalizePotentialPath(input: string): string {
513
+ let current = resolve(input);
514
+ const missingSegments: string[] = [];
515
+
516
+ while (!lstatExists(current)) {
517
+ const parent = dirname(current);
518
+ if (parent === current) break;
519
+ missingSegments.unshift(basename(current));
520
+ current = parent;
521
+ }
522
+
523
+ const realBase = realpathSync(current);
524
+ return missingSegments.length > 0 ? join(realBase, ...missingSegments) : realBase;
525
+ }
526
+
527
+ /** 判断候选路径是否等于允许根目录或位于其后代中。 */
528
+ function isWithinRoot(candidate: string, root: string): boolean {
529
+ const rel = relative(root, candidate);
530
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
531
+ }
532
+
533
+ /** 将 --option=value 拆为选项名和值。 */
534
+ function splitOption(value: string): [string, string | undefined] {
535
+ const equalsIndex = value.indexOf("=");
536
+ if (equalsIndex === -1) return [value, undefined];
537
+ return [value.slice(0, equalsIndex), value.slice(equalsIndex + 1)];
538
+ }
539
+
540
+ /** 只展开可确定的 HOME/PWD/OLDPWD/TMPDIR 与波浪号前缀。 */
541
+ function expandKnownPathPrefix(value: string, cwd: string): string {
542
+ if (value === "~") return homedir();
543
+ if (value.startsWith("~/")) return join(homedir(), value.slice("~/".length));
544
+
545
+ const replacements: Array<[RegExp, string | undefined]> = [
546
+ [/^\$\{?HOME\}?/, homedir()],
547
+ [/^\$\{?PWD\}?/, cwd],
548
+ [/^\$\{?OLDPWD\}?/, process.env.OLDPWD],
549
+ [/^\$\{?TMPDIR\}?/, process.env.TMPDIR],
550
+ ];
551
+
552
+ for (const [pattern, replacement] of replacements) {
553
+ if (replacement && pattern.test(value)) return value.replace(pattern, replacement);
554
+ }
555
+ return value;
556
+ }
557
+
558
+ /** 未知变量或命令替换位于路径开头时无法静态确定,交由 shell 自身处理。 */
559
+ function startsWithUnknownExpansion(value: string): boolean {
560
+ return value.startsWith("$") || value.startsWith("`");
561
+ }
562
+
563
+ /** 排除 http/ssh 等远程 URL,file:// 由本地路径逻辑单独处理。 */
564
+ function looksLikeUrl(value: string): boolean {
565
+ return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
566
+ }
567
+
568
+ /** lstat 同时识别普通路径和指向不存在目标的符号链接。 */
569
+ function lstatExists(path: string): boolean {
570
+ try {
571
+ lstatSync(path);
572
+ return true;
573
+ } catch (error) {
574
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
575
+ throw error;
576
+ }
577
+ }
578
+
579
+ /** 将 unknown 安全收窄为普通对象。 */
580
+ function isRecord(value: unknown): value is Record<string, unknown> {
581
+ return typeof value === "object" && value !== null;
582
+ }
583
+
584
+ /** 识别会打开文件的重定向节点,排除 heredoc、herestring 和 fd 复制。 */
585
+ function isFileRedirect(value: Record<string, unknown>): boolean {
586
+ const operator = value.operator;
587
+ const isFileOperator = operator === ">" || operator === ">>" || operator === "<" ||
588
+ operator === "<>" || operator === ">|" || operator === "&>" || operator === "&>>";
589
+ return isFileOperator && "target" in value && "fileDescriptor" in value;
590
+ }