@dieulc/workflow 0.9.2 → 0.11.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/shell-paths.ts ADDED
@@ -0,0 +1,1045 @@
1
+ /**
2
+ * Workflow Extension — Shell path analysis
3
+ *
4
+ * One quote-aware extraction of the file paths a shell command READS and
5
+ * WRITES. The read policy consumes `reads`; the write policy consumes
6
+ * `writes`; the commit policy consumes `git`.
7
+ *
8
+ * WHY THIS EXISTS: command policy used to answer "may this write?" without
9
+ * knowing WHICH files a command touches. `cp ../.env ./x` was just "mutating".
10
+ * The folder floor needs concrete targets. This module is deliberately
11
+ * conservative: it extracts what it can prove, marks interpreters and
12
+ * unparseable constructs as `uncertain`, and leaves unknown write commands to
13
+ * the write policy (a documented residual — this is not a sandbox).
14
+ *
15
+ * Pure: imports only `permissions.ts` / `shell-text.ts` and path helpers from
16
+ * `folder-policy.ts`; no Pi SDK, no filesystem reads beyond normalisation.
17
+ */
18
+
19
+ import {
20
+ analyzeCommand,
21
+ type SegmentAnalysis,
22
+ } from "./permissions.ts";
23
+ import type { ShellRedirect, ShellWord } from "./shell-text.ts";
24
+ import {
25
+ type FolderTarget,
26
+ normalizePolicyPath,
27
+ } from "./folder-policy.ts";
28
+
29
+ export type GitCommitHit = "add" | "commit" | "push";
30
+
31
+ export interface GitScope {
32
+ hit: GitCommitHit;
33
+ /** Canonical-ish working/repository root implied by -C/--git-dir/-work-tree. */
34
+ repoRoot: string;
35
+ /** Explicit path operands (add/commit/checkout/restore/switch). */
36
+ explicitPaths: string[];
37
+ /** `git add .` / `-A` / `--all` — the caller must scan the worktree. */
38
+ allPaths: boolean;
39
+ amend: boolean;
40
+ /** True when no explicit root flag was given (repo is cwd's repository). */
41
+ usesCwd: boolean;
42
+ }
43
+
44
+ export interface ShellPathAnalysis {
45
+ reads: FolderTarget[];
46
+ writes: FolderTarget[];
47
+ git?: GitScope;
48
+ /** Reasons the command could not be statically verified. */
49
+ uncertain: string[];
50
+ }
51
+
52
+ const READ_COMMANDS = new Set([
53
+ "cat",
54
+ "tac",
55
+ "head",
56
+ "tail",
57
+ "less",
58
+ "more",
59
+ "bat",
60
+ "batcat",
61
+ "nl",
62
+ "rev",
63
+ "fold",
64
+ "fmt",
65
+ "column",
66
+ "cut",
67
+ "tr",
68
+ "wc",
69
+ "uniq",
70
+ "diff",
71
+ "cmp",
72
+ "comm",
73
+ "join",
74
+ "paste",
75
+ "grep",
76
+ "egrep",
77
+ "fgrep",
78
+ "rg",
79
+ "ag",
80
+ "ack",
81
+ "fd",
82
+ "fdfind",
83
+ "find",
84
+ "ls",
85
+ "tree",
86
+ "eza",
87
+ "du",
88
+ "stat",
89
+ "file",
90
+ "realpath",
91
+ "readlink",
92
+ "xxd",
93
+ "hexdump",
94
+ "strings",
95
+ "md5sum",
96
+ "sha1sum",
97
+ "sha256sum",
98
+ "sha512sum",
99
+ "cksum",
100
+ "base64",
101
+ "jq",
102
+ "yq",
103
+ "zcat",
104
+ "bzcat",
105
+ "xzcat",
106
+ "zipinfo",
107
+ "ffmpeg",
108
+ "openssl",
109
+ ]);
110
+
111
+ const WRITE_COMMANDS = new Set([
112
+ "tee",
113
+ "mkdir",
114
+ "rmdir",
115
+ "rm",
116
+ "touch",
117
+ "truncate",
118
+ "chmod",
119
+ "chown",
120
+ "ln",
121
+ "cp",
122
+ "mv",
123
+ "install",
124
+ "rsync",
125
+ "scp",
126
+ "dd",
127
+ ]);
128
+
129
+ const WRAPPERS = new Set([
130
+ "env",
131
+ "nice",
132
+ "time",
133
+ "timeout",
134
+ "nohup",
135
+ "stdbuf",
136
+ "command",
137
+ "builtin",
138
+ "sudo",
139
+ ]);
140
+
141
+ const SHELLS = new Set([
142
+ "sh",
143
+ "bash",
144
+ "dash",
145
+ "zsh",
146
+ "fish",
147
+ "ksh",
148
+ "pwsh",
149
+ "powershell",
150
+ "cmd",
151
+ ]);
152
+
153
+ const INTERPRETERS = new Set([
154
+ "node",
155
+ "python",
156
+ "python3",
157
+ "perl",
158
+ "ruby",
159
+ "php",
160
+ ]);
161
+
162
+ const POWERSHELL_READS = new Set([
163
+ "get-content",
164
+ "gc",
165
+ "select-string",
166
+ "sls",
167
+ "get-childitem",
168
+ "gci",
169
+ "get-item",
170
+ "gi",
171
+ "resolve-path",
172
+ "rvpa",
173
+ "test-path",
174
+ "findstr",
175
+ "type",
176
+ "dir",
177
+ ]);
178
+
179
+ const POWERSHELL_WRITES = new Set([
180
+ "set-content",
181
+ "add-content",
182
+ "clear-content",
183
+ "out-file",
184
+ "new-item",
185
+ "remove-item",
186
+ "copy-item",
187
+ "move-item",
188
+ "rename-item",
189
+ "copy",
190
+ "move",
191
+ "del",
192
+ "erase",
193
+ "md",
194
+ "rd",
195
+ ]);
196
+
197
+ function values(words: readonly ShellWord[]): string[] {
198
+ return words.map((word) => word.value);
199
+ }
200
+
201
+ function isFlag(value: string): boolean {
202
+ return value.startsWith("-") && value !== "-";
203
+ }
204
+
205
+ function looksLikeOptionValue(flag: string): boolean {
206
+ return /^-[A-Za-z]$/.test(flag) || /^--[^=]+$/.test(flag);
207
+ }
208
+
209
+ function dedupeTargets(targets: FolderTarget[]): FolderTarget[] {
210
+ const seen = new Set<string>();
211
+ const out: FolderTarget[] = [];
212
+ for (const target of targets) {
213
+ const key = `${target.kind}\0${target.path}`;
214
+ if (seen.has(key)) continue;
215
+ seen.add(key);
216
+ out.push(target);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * Extract read/write targets and, for git add/commit/push, the scope the
223
+ * commit policy needs. Total: non-strings produce an empty analysis.
224
+ */
225
+ export function analyzeShellPaths(
226
+ command: unknown,
227
+ cwd: string,
228
+ ): ShellPathAnalysis {
229
+ const analysis: ShellPathAnalysis = {
230
+ reads: [],
231
+ writes: [],
232
+ uncertain: [],
233
+ };
234
+ if (typeof command !== "string" || !command.trim()) return analysis;
235
+ const parsed = analyzeCommand(command);
236
+ let virtualCwd = normalizePolicyPath(cwd, cwd);
237
+
238
+ // Best-effort command substitution: `echo $(cat ../x)` / backticks. Only
239
+ // simple, non-nested bodies are parsed; the rest stays uncertain.
240
+ for (const match of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)) {
241
+ const inner = (match[1] ?? match[2] ?? "").trim();
242
+ if (!inner) continue;
243
+ const nested = analyzeShellPaths(inner, virtualCwd);
244
+ for (const target of nested.reads) analysis.reads.push(target);
245
+ for (const target of nested.writes) analysis.writes.push(target);
246
+ for (const reason of nested.uncertain) analysis.uncertain.push(reason);
247
+ if (nested.git) analysis.git = nested.git;
248
+ }
249
+
250
+ const addRead = (value: string, kind: FolderTarget["kind"] = "file") => {
251
+ const normalized = normalizePolicyPath(value, virtualCwd);
252
+ if (!normalized || normalized === "-") return;
253
+ if (/^\d+$/.test(normalized)) return;
254
+ if (normalized.endsWith("/dev/null") || normalized.endsWith("/dev/stdin"))
255
+ return;
256
+ if (normalized.endsWith("/dev/stdout") || normalized.endsWith("/dev/stderr"))
257
+ return;
258
+ analysis.reads.push({ path: normalized, kind });
259
+ };
260
+ const addWrite = (value: string, kind: FolderTarget["kind"] = "file") => {
261
+ const normalized = normalizePolicyPath(value, virtualCwd);
262
+ if (!normalized || normalized === "-") return;
263
+ if (normalized.endsWith("/dev/null")) return;
264
+ analysis.writes.push({ path: normalized, kind });
265
+ };
266
+
267
+ for (const segment of parsed.segments) {
268
+ analyzeSegment(segment, {
269
+ analysis,
270
+ addRead,
271
+ addWrite,
272
+ get cwd() {
273
+ return virtualCwd;
274
+ },
275
+ set cwd(value: string) {
276
+ virtualCwd = value;
277
+ },
278
+ });
279
+ }
280
+
281
+ analysis.reads = dedupeTargets(analysis.reads);
282
+ analysis.writes = dedupeTargets(analysis.writes);
283
+ return analysis;
284
+ }
285
+
286
+ interface SegmentContext {
287
+ analysis: ShellPathAnalysis;
288
+ cwd: string;
289
+ addRead: (value: string, kind?: FolderTarget["kind"]) => void;
290
+ addWrite: (value: string, kind?: FolderTarget["kind"]) => void;
291
+ }
292
+
293
+ function analyzeSegment(segment: SegmentAnalysis, ctx: SegmentContext): void {
294
+ for (const redirect of segment.redirects) {
295
+ if (!redirect.target) continue;
296
+ const op = redirect.op;
297
+ if (op === "<" || op === "<<<" || op === "<&") {
298
+ if (op === "<") ctx.addRead(redirect.target);
299
+ continue;
300
+ }
301
+ if (op === ">" || op === ">>" || op === "&>" || op === "&>>" || op === "2>") {
302
+ ctx.addWrite(redirect.target);
303
+ }
304
+ }
305
+
306
+ let words = values(segment.words);
307
+ let command = segment.command;
308
+ if (!command) return;
309
+
310
+ if (command === "cd") {
311
+ const target = words.slice(1).find((word) => !isFlag(word));
312
+ if (!target) {
313
+ if (words.length > 1 && words[1] === "-") ctx.analysis.uncertain.push("cd -");
314
+ return;
315
+ }
316
+ if (/\$|%|`/.test(target)) {
317
+ ctx.analysis.uncertain.push(`cd ${target}`);
318
+ return;
319
+ }
320
+ const next = normalizePolicyPath(target, ctx.cwd);
321
+ if (next) ctx.cwd = next;
322
+ return;
323
+ }
324
+ if (command === "pushd" || command === "popd") {
325
+ ctx.analysis.uncertain.push(command);
326
+ return;
327
+ }
328
+
329
+ // Strip wrappers and leading VAR=value assignments.
330
+ let guard = 0;
331
+ while (guard++ < 8) {
332
+ const stripped = stripWrappersAndAssignments(words);
333
+ if (stripped.length === words.length) break;
334
+ words = stripped;
335
+ command = baseCommand(words[0] ?? "");
336
+ }
337
+
338
+ if (command === "git") {
339
+ analyzeGit(words, ctx);
340
+ return;
341
+ }
342
+ if (POWERSHELL_READS.has(command)) {
343
+ analyzePowerShellRead(words, ctx);
344
+ return;
345
+ }
346
+ if (POWERSHELL_WRITES.has(command)) {
347
+ analyzePowerShellWrite(words, ctx, command);
348
+ return;
349
+ }
350
+ if (command === "grep" || command === "egrep" || command === "fgrep" || command === "rg" || command === "ag" || command === "ack") {
351
+ analyzeGrep(words, ctx);
352
+ return;
353
+ }
354
+ if (command === "find") {
355
+ analyzeFind(words, ctx);
356
+ return;
357
+ }
358
+ if (command === "sed") {
359
+ analyzeSed(words, ctx);
360
+ return;
361
+ }
362
+ if (command === "awk") {
363
+ analyzeAwk(words, ctx);
364
+ return;
365
+ }
366
+ if (command === "tar") {
367
+ analyzeTar(words, ctx);
368
+ return;
369
+ }
370
+ if (command === "unzip" || command === "7z" || command === "zipinfo") {
371
+ analyzeArchive(words, ctx, command);
372
+ return;
373
+ }
374
+ if (command === "curl" || command === "wget") {
375
+ analyzeCurl(words, ctx);
376
+ return;
377
+ }
378
+ if (command === "cp" || command === "mv" || command === "install" || command === "rsync" || command === "scp") {
379
+ analyzeCopy(words, ctx, command);
380
+ return;
381
+ }
382
+ if (READ_COMMANDS.has(command)) {
383
+ analyzeReadCommand(words, ctx, command);
384
+ return;
385
+ }
386
+ if (WRITE_COMMANDS.has(command)) {
387
+ analyzeWriteCommand(words, ctx, command);
388
+ return;
389
+ }
390
+ if (command === "tee") {
391
+ for (const word of words.slice(1)) if (!isFlag(word)) ctx.addWrite(word);
392
+ return;
393
+ }
394
+ if (SHELLS.has(command)) {
395
+ analyzeShell(words, ctx, command);
396
+ return;
397
+ }
398
+ if (INTERPRETERS.has(command)) {
399
+ analyzeInterpreter(words, ctx, command);
400
+ return;
401
+ }
402
+ if (command === "source" || command === ".") {
403
+ const target = words.slice(1).find((word) => !isFlag(word));
404
+ if (target) ctx.addRead(target);
405
+ return;
406
+ }
407
+ if (command === "eval" || command === "exec") {
408
+ ctx.analysis.uncertain.push(command);
409
+ return;
410
+ }
411
+ if (command === "xargs") {
412
+ ctx.analysis.uncertain.push("xargs (paths arrive on stdin)");
413
+ return;
414
+ }
415
+ if (["npm", "npx", "pnpm", "yarn", "bun"].includes(command)) {
416
+ ctx.analysis.uncertain.push(
417
+ `${command} scripts can read/write arbitrary paths`,
418
+ );
419
+ }
420
+
421
+ // Unknown command: only flag operands that are obviously sensitive. This
422
+ // catches `some-tool .env` without mistaking every write destination for a
423
+ // read. The folder floor / write mode still governs the rest.
424
+ if (segment.commandClass === "full") {
425
+ ctx.analysis.uncertain.push(`${command} (cannot be statically classified)`);
426
+ }
427
+ for (const word of words.slice(1)) {
428
+ if (isFlag(word) || word === "-") continue;
429
+ const normalized = normalizePolicyPath(word, ctx.cwd);
430
+ if (looksSensitiveOperand(normalized)) ctx.addRead(word);
431
+ }
432
+ }
433
+
434
+ function baseCommand(value: string): string {
435
+ const normalized = value.replace(/\\/g, "/");
436
+ const name = normalized.split("/").pop() ?? normalized;
437
+ return name.toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/i, "");
438
+ }
439
+
440
+ function stripWrappersAndAssignments(words: string[]): string[] {
441
+ let index = 0;
442
+ while (index < words.length) {
443
+ const word = words[index];
444
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
445
+ index++;
446
+ continue;
447
+ }
448
+ if (!WRAPPERS.has(baseCommand(word))) break;
449
+ const command = baseCommand(word);
450
+ index++;
451
+ while (index < words.length && isFlag(words[index])) {
452
+ const flag = words[index];
453
+ index++;
454
+ if (looksLikeOptionValue(flag) && index < words.length && !words[index].startsWith("-")) {
455
+ // Wrapper option values: nice -n 5, timeout 30, sudo -u user.
456
+ if (command === "nice" && flag === "-n") index++;
457
+ else if (command === "timeout") index++;
458
+ else if (command === "sudo" && (flag === "-u" || flag === "-g")) index++;
459
+ else if (command === "env" && (flag === "-u" || flag === "--unset")) index++;
460
+ else if (command === "stdbuf") index++;
461
+ }
462
+ }
463
+ if (command === "timeout" && index < words.length && !words[index].startsWith("-")) {
464
+ index++; // duration
465
+ }
466
+ if (command === "sudo" && index < words.length && !words[index].startsWith("-")) {
467
+ // `sudo user command` is rare; only strip a -u value, already handled.
468
+ }
469
+ }
470
+ return words.slice(index);
471
+ }
472
+
473
+ function analyzeReadCommand(
474
+ words: string[],
475
+ ctx: SegmentContext,
476
+ command: string,
477
+ ): void {
478
+ const optionsWithValue = new Set([
479
+ "-n",
480
+ "-c",
481
+ "--bytes",
482
+ "--lines",
483
+ "--max-count",
484
+ "--color",
485
+ "--exclude",
486
+ "--include",
487
+ "--glob",
488
+ "--type",
489
+ "--file",
490
+ "--files0-from",
491
+ ]);
492
+ const args: string[] = [];
493
+ for (let i = 1; i < words.length; i++) {
494
+ const word = words[i];
495
+ if (isFlag(word)) {
496
+ if (optionsWithValue.has(word) && i + 1 < words.length) i++;
497
+ if (word === "--" && i + 1 < words.length) {
498
+ args.push(...words.slice(i + 1));
499
+ break;
500
+ }
501
+ continue;
502
+ }
503
+ args.push(word);
504
+ }
505
+ if (args.length === 0) {
506
+ // `cat`/`head`/`wc` with no operand read stdin, not the directory. Only
507
+ // commands whose documented default is the current directory add it.
508
+ if (["ls", "tree", "eza", "du", "fd", "fdfind"].includes(command))
509
+ ctx.addRead(ctx.cwd, "dir");
510
+ return;
511
+ }
512
+ for (const arg of args) ctx.addRead(arg);
513
+ }
514
+
515
+ function analyzeWriteCommand(
516
+ words: string[],
517
+ ctx: SegmentContext,
518
+ command: string,
519
+ ): void {
520
+ const args = words.slice(1).filter((word) => !isFlag(word) && word !== "-");
521
+ if (args.length === 0) {
522
+ ctx.analysis.uncertain.push(`${command} without a statically known target`);
523
+ return;
524
+ }
525
+ for (const arg of args) ctx.addWrite(arg);
526
+ }
527
+
528
+ function analyzeGrep(words: string[], ctx: SegmentContext): void {
529
+ const optionsWithValue = new Set([
530
+ "-e",
531
+ "--regexp",
532
+ "-f",
533
+ "--file",
534
+ "-g",
535
+ "--glob",
536
+ "--type",
537
+ "-C",
538
+ "-A",
539
+ "-B",
540
+ "--context",
541
+ "--after-context",
542
+ "--before-context",
543
+ "--max-count",
544
+ "--exclude",
545
+ "--include",
546
+ ]);
547
+ const positionals: string[] = [];
548
+ for (let i = 1; i < words.length; i++) {
549
+ const word = words[i];
550
+ if (word === "--") {
551
+ positionals.push(...words.slice(i + 1));
552
+ break;
553
+ }
554
+ if (isFlag(word)) {
555
+ if (word === "-f" || word === "--file") {
556
+ if (i + 1 < words.length) ctx.addRead(words[++i]);
557
+ continue;
558
+ }
559
+ if (optionsWithValue.has(word) && i + 1 < words.length) i++;
560
+ continue;
561
+ }
562
+ positionals.push(word);
563
+ }
564
+ // The first positional is the pattern unless -e/-f supplied it.
565
+ const hasExplicitPattern = words.some(
566
+ (word) => word === "-e" || word === "--regexp" || word === "-f" || word === "--file",
567
+ );
568
+ const paths = hasExplicitPattern ? positionals : positionals.slice(1);
569
+ if (paths.length === 0) {
570
+ ctx.addRead(ctx.cwd, "dir");
571
+ return;
572
+ }
573
+ for (const p of paths) ctx.addRead(p);
574
+ }
575
+
576
+ function analyzeFind(words: string[], ctx: SegmentContext): void {
577
+ let root: string | undefined;
578
+ for (let i = 1; i < words.length; i++) {
579
+ const word = words[i];
580
+ if (word === "-newer" || word === "-files0-from") {
581
+ if (i + 1 < words.length) ctx.addRead(words[++i]);
582
+ continue;
583
+ }
584
+ if (word === "-exec" || word === "-execdir" || word === "-ok") {
585
+ ctx.analysis.uncertain.push(`find ${word}`);
586
+ continue;
587
+ }
588
+ if (isFlag(word)) continue;
589
+ if (!root) root = word;
590
+ }
591
+ ctx.addRead(root ?? ctx.cwd, "dir");
592
+ }
593
+
594
+ function analyzeSed(words: string[], ctx: SegmentContext): void {
595
+ const files: string[] = [];
596
+ let scriptSeen = false;
597
+ for (let i = 1; i < words.length; i++) {
598
+ const word = words[i];
599
+ if (word === "-e" || word === "--expression") {
600
+ scriptSeen = true;
601
+ if (i + 1 < words.length) i++;
602
+ continue;
603
+ }
604
+ if (word === "-f" || word === "--file") {
605
+ if (i + 1 < words.length) ctx.addRead(words[++i]);
606
+ scriptSeen = true;
607
+ continue;
608
+ }
609
+ if (word.startsWith("-i") || word === "--in-place") {
610
+ ctx.analysis.uncertain.push("sed -i");
611
+ continue;
612
+ }
613
+ if (isFlag(word)) continue;
614
+ if (!scriptSeen) {
615
+ scriptSeen = true;
616
+ if (/\b[ew]\b/.test(word)) ctx.analysis.uncertain.push("sed script executes/writes");
617
+ continue;
618
+ }
619
+ files.push(word);
620
+ }
621
+ for (const file of files) {
622
+ ctx.addRead(file);
623
+ if (words.some((word) => word.startsWith("-i") || word === "--in-place"))
624
+ ctx.addWrite(file);
625
+ }
626
+ }
627
+
628
+ function analyzeAwk(words: string[], ctx: SegmentContext): void {
629
+ const files: string[] = [];
630
+ let programSeen = false;
631
+ for (let i = 1; i < words.length; i++) {
632
+ const word = words[i];
633
+ if (word === "-f" || word === "--file") {
634
+ if (i + 1 < words.length) ctx.addRead(words[++i]);
635
+ programSeen = true;
636
+ continue;
637
+ }
638
+ if (word === "-i" || word === "--include") continue;
639
+ if (isFlag(word)) continue;
640
+ if (!programSeen) {
641
+ programSeen = true;
642
+ if (/system\s*\(|\|\s*["']/.test(word))
643
+ ctx.analysis.uncertain.push("awk program executes a command");
644
+ continue;
645
+ }
646
+ files.push(word);
647
+ }
648
+ for (const file of files) {
649
+ ctx.addRead(file);
650
+ if (words.includes("inplace") || words.includes("-i"))
651
+ ctx.addWrite(file);
652
+ }
653
+ }
654
+
655
+ function analyzeTar(words: string[], ctx: SegmentContext): void {
656
+ let extracting = false;
657
+ for (let i = 1; i < words.length; i++) {
658
+ const word = words[i];
659
+ if (word === "-f" || word === "--file") {
660
+ if (i + 1 < words.length) ctx.addRead(words[++i]);
661
+ continue;
662
+ }
663
+ if (word === "-C" || word === "--directory") {
664
+ if (i + 1 < words.length) ctx.addWrite(words[++i], "dir");
665
+ continue;
666
+ }
667
+ if (/^[-]?[xXtT]/.test(word)) extracting = true;
668
+ if (/^-/.test(word) && /[xX]/.test(word)) extracting = true;
669
+ if (!isFlag(word)) ctx.addRead(word);
670
+ }
671
+ if (extracting) ctx.addWrite(ctx.cwd, "dir");
672
+ }
673
+
674
+ function analyzeArchive(
675
+ words: string[],
676
+ ctx: SegmentContext,
677
+ command: string,
678
+ ): void {
679
+ for (let i = 1; i < words.length; i++) {
680
+ const word = words[i];
681
+ if (word === "-d" || word === "-o") {
682
+ if (i + 1 < words.length) ctx.addWrite(words[++i], "dir");
683
+ continue;
684
+ }
685
+ if (command === "unzip" && word === "-d") continue;
686
+ if (isFlag(word)) continue;
687
+ if (/\.(zip|tar|gz|tgz|7z|rar|xz|bz2)$/i.test(word) || i === 1)
688
+ ctx.addRead(word);
689
+ else ctx.addWrite(word);
690
+ }
691
+ }
692
+
693
+ function analyzeCurl(words: string[], ctx: SegmentContext): void {
694
+ for (let i = 1; i < words.length; i++) {
695
+ const word = words[i];
696
+ if (word === "-o" || word === "--output") {
697
+ if (i + 1 < words.length) ctx.addWrite(words[++i]);
698
+ continue;
699
+ }
700
+ if (word === "-K" || word === "--config") {
701
+ if (i + 1 < words.length) ctx.addRead(words[++i]);
702
+ continue;
703
+ }
704
+ if (word.startsWith("@")) {
705
+ ctx.addRead(word.slice(1));
706
+ continue;
707
+ }
708
+ if (word.startsWith("file://")) {
709
+ ctx.addRead(word.slice("file://".length));
710
+ continue;
711
+ }
712
+ if (word.startsWith("--data") || word.startsWith("--form")) continue;
713
+ if (isFlag(word)) continue;
714
+ if (/^https?:\/\//i.test(word)) continue;
715
+ if (word.startsWith("@")) ctx.addRead(word.slice(1));
716
+ }
717
+ }
718
+
719
+ function analyzeCopy(
720
+ words: string[],
721
+ ctx: SegmentContext,
722
+ command: string,
723
+ ): void {
724
+ const args: string[] = [];
725
+ let recursive = command === "rsync";
726
+ for (let i = 1; i < words.length; i++) {
727
+ const word = words[i];
728
+ if (isFlag(word)) {
729
+ if (word === "-r" || word === "-R" || word === "-a" || word === "-A")
730
+ recursive = true;
731
+ continue;
732
+ }
733
+ args.push(word);
734
+ }
735
+ if (args.length < 2) {
736
+ ctx.analysis.uncertain.push(`${command} without a destination`);
737
+ return;
738
+ }
739
+ const sources = args.slice(0, -1);
740
+ const destination = args[args.length - 1];
741
+ for (const source of sources) {
742
+ if (/^[^/\\]+@[^:]+:/.test(source)) continue; // remote scp/rsync source
743
+ ctx.addRead(source, recursive ? "dir" : "file");
744
+ }
745
+ if (/^[^/\\]+@[^:]+:/.test(destination)) return; // remote destination
746
+ ctx.addWrite(destination, recursive ? "dir" : "file");
747
+ }
748
+
749
+ function analyzeShell(
750
+ words: string[],
751
+ ctx: SegmentContext,
752
+ command: string,
753
+ ): void {
754
+ for (let i = 1; i < words.length; i++) {
755
+ const word = words[i];
756
+ if (word === "-c" || word === "/c" || word === "-Command" || word === "-command") {
757
+ const inner = words.slice(i + 1).join(" ");
758
+ if (!inner) {
759
+ ctx.analysis.uncertain.push(`${command} ${word} without a command`);
760
+ return;
761
+ }
762
+ mergeAnalysis(ctx, analyzeShellPaths(inner, ctx.cwd));
763
+ return;
764
+ }
765
+ if (isFlag(word)) continue;
766
+ ctx.addRead(word);
767
+ }
768
+ }
769
+
770
+ function analyzeInterpreter(
771
+ words: string[],
772
+ ctx: SegmentContext,
773
+ command: string,
774
+ ): void {
775
+ for (let i = 1; i < words.length; i++) {
776
+ const word = words[i];
777
+ if (word === "-e" || word === "-c" || word === "-m" || word === "-r") {
778
+ ctx.analysis.uncertain.push(`${command} ${word} inline code`);
779
+ return;
780
+ }
781
+ if (isFlag(word)) continue;
782
+ ctx.addRead(word);
783
+ return;
784
+ }
785
+ ctx.analysis.uncertain.push(`${command} (inline or stdin code)`);
786
+ }
787
+
788
+ function mergeAnalysis(ctx: SegmentContext, nested: ShellPathAnalysis): void {
789
+ for (const target of nested.reads) ctx.addRead(target.path, target.kind);
790
+ for (const target of nested.writes) ctx.addWrite(target.path, target.kind);
791
+ for (const reason of nested.uncertain) ctx.analysis.uncertain.push(reason);
792
+ if (nested.git) ctx.analysis.git = nested.git;
793
+ }
794
+
795
+ function analyzeGit(words: string[], ctx: SegmentContext): void {
796
+ const command = words;
797
+ let index = 1;
798
+ let rootFlag: string | undefined;
799
+ let usesCwd = true;
800
+ while (index < command.length) {
801
+ const word = command[index];
802
+ if (word === "-C" || word === "--git-dir" || word === "--work-tree") {
803
+ rootFlag = command[index + 1];
804
+ usesCwd = false;
805
+ index += 2;
806
+ continue;
807
+ }
808
+ if (word.startsWith("--git-dir=") || word.startsWith("--work-tree=")) {
809
+ rootFlag = word.slice(word.indexOf("=") + 1);
810
+ usesCwd = false;
811
+ index++;
812
+ continue;
813
+ }
814
+ if (word === "-c" || word === "--config-env" || word === "--exec-path" || word === "--namespace" || word === "--super-prefix") {
815
+ const value = command[index + 1] ?? "";
816
+ if (/fsmonitor|pager|alias\./i.test(value))
817
+ ctx.analysis.uncertain.push("git -c may execute a configured program");
818
+ index += 2;
819
+ continue;
820
+ }
821
+ if (word.startsWith("-")) {
822
+ index++;
823
+ continue;
824
+ }
825
+ break;
826
+ }
827
+ const subcommand = (command[index] ?? "").toLowerCase();
828
+ const args = command.slice(index + 1);
829
+ const repoRoot = rootFlag
830
+ ? normalizePolicyPath(rootFlag, ctx.cwd)
831
+ : normalizePolicyPath(ctx.cwd, ctx.cwd);
832
+ if (rootFlag) {
833
+ // Git metadata and worktree are both read; the commit policy uses repoRoot.
834
+ ctx.addRead(rootFlag, "dir");
835
+ }
836
+
837
+ if (subcommand === "add" || subcommand === "commit" || subcommand === "push") {
838
+ const explicitPaths: string[] = [];
839
+ let allPaths = false;
840
+ let amend = false;
841
+ let afterSeparator = false;
842
+ for (let i = 0; i < args.length; i++) {
843
+ const word = args[i];
844
+ if (word === "--") {
845
+ afterSeparator = true;
846
+ continue;
847
+ }
848
+ if (word === "-A" || word === "--all") {
849
+ allPaths = true;
850
+ continue;
851
+ }
852
+ if (subcommand === "commit" && word === "-a") {
853
+ allPaths = true;
854
+ continue;
855
+ }
856
+ if (word === "--patch") continue;
857
+ if (word === "--amend") {
858
+ amend = true;
859
+ continue;
860
+ }
861
+ if (!afterSeparator && isFlag(word)) {
862
+ if (
863
+ ["-m", "--message", "-F", "--file", "--author", "--date", "-C", "-c", "--fixup", "--squash"].includes(word)
864
+ ) {
865
+ if (word === "-F" || word === "--file") {
866
+ if (args[i + 1]) ctx.addRead(args[i + 1]);
867
+ }
868
+ i++;
869
+ }
870
+ continue;
871
+ }
872
+ if (subcommand === "push") continue; // remote names, not local paths
873
+ explicitPaths.push(word);
874
+ }
875
+ if (
876
+ subcommand === "add" &&
877
+ explicitPaths.some((p) => p === "." || p === ":/" || p === ":")
878
+ )
879
+ allPaths = true;
880
+ for (const p of explicitPaths) {
881
+ ctx.addRead(p);
882
+ ctx.addWrite(p);
883
+ }
884
+ ctx.analysis.git = {
885
+ hit: subcommand,
886
+ repoRoot,
887
+ explicitPaths: explicitPaths.map((p) => normalizePolicyPath(p, repoRoot)),
888
+ allPaths,
889
+ amend,
890
+ usesCwd,
891
+ };
892
+ return;
893
+ }
894
+
895
+ if (subcommand === "checkout" || subcommand === "restore" || subcommand === "switch") {
896
+ let afterSeparator = false;
897
+ for (let i = 0; i < args.length; i++) {
898
+ const word = args[i];
899
+ if (word === "--") {
900
+ afterSeparator = true;
901
+ continue;
902
+ }
903
+ if (isFlag(word)) {
904
+ if (["-b", "-B", "--orphan"].includes(word)) i++;
905
+ continue;
906
+ }
907
+ // A branch name is indistinguishable from a path; both resolve inside
908
+ // repoRoot, so target them and let the evaluator decide.
909
+ if (!afterSeparator && /^[A-Za-z0-9._/-]+$/.test(word)) {
910
+ ctx.addWrite(word);
911
+ } else ctx.addWrite(word);
912
+ }
913
+ return;
914
+ }
915
+
916
+ if (subcommand === "clone") {
917
+ const source = args.find((word) => !isFlag(word));
918
+ const positional = args.filter((word) => !isFlag(word));
919
+ if (source && !/^https?:\/\//i.test(source) && !source.startsWith("git@"))
920
+ ctx.addRead(source, "dir");
921
+ if (positional.length > 1) ctx.addWrite(positional[1], "dir");
922
+ return;
923
+ }
924
+
925
+ if (subcommand === "show" || subcommand === "cat-file") {
926
+ for (const arg of args) {
927
+ const colon = arg.indexOf(":");
928
+ if (colon > 0 && !arg.includes("://")) {
929
+ const file = arg.slice(colon + 1);
930
+ if (file) addGitRead(ctx, file, repoRoot);
931
+ }
932
+ }
933
+ if (subcommand === "show" && !args.some((arg) => arg.includes(":"))) {
934
+ ctx.addRead(repoRoot, "dir");
935
+ }
936
+ return;
937
+ }
938
+
939
+ if (subcommand === "diff" || subcommand === "log" || subcommand === "grep" || subcommand === "blame") {
940
+ const separator = args.indexOf("--");
941
+ const paths = separator >= 0 ? args.slice(separator + 1) : [];
942
+ for (const p of paths) addGitRead(ctx, p, repoRoot);
943
+ if (paths.length === 0) ctx.addRead(repoRoot, "dir");
944
+ return;
945
+ }
946
+
947
+ // Other git subcommands read the repository at least.
948
+ ctx.addRead(repoRoot, "dir");
949
+ }
950
+
951
+ function addGitRead(
952
+ ctx: SegmentContext,
953
+ value: string,
954
+ repoRoot: string,
955
+ ): void {
956
+ const normalized = normalizePolicyPath(value, repoRoot);
957
+ if (normalized) ctx.analysis.reads.push({ path: normalized, kind: "file" });
958
+ }
959
+
960
+ function analyzePowerShellRead(words: string[], ctx: SegmentContext): void {
961
+ const args = powerShellPathArgs(words);
962
+ if (args.length === 0) ctx.addRead(ctx.cwd, "dir");
963
+ else for (const arg of args) ctx.addRead(arg);
964
+ }
965
+
966
+ function analyzePowerShellWrite(
967
+ words: string[],
968
+ ctx: SegmentContext,
969
+ command: string,
970
+ ): void {
971
+ const args = powerShellPathArgs(words);
972
+ if (args.length === 0) {
973
+ ctx.analysis.uncertain.push(`${command} without a statically known target`);
974
+ return;
975
+ }
976
+ if (command === "copy-item" || command === "move-item" || command === "copy" || command === "move") {
977
+ for (const source of args.slice(0, -1)) ctx.addRead(source);
978
+ ctx.addWrite(args[args.length - 1]);
979
+ return;
980
+ }
981
+ for (const arg of args) ctx.addWrite(arg);
982
+ }
983
+
984
+ function powerShellPathArgs(words: string[]): string[] {
985
+ const pathFlags = new Set([
986
+ "-path",
987
+ "-literalpath",
988
+ "-destination",
989
+ "-source",
990
+ "-filepath",
991
+ ]);
992
+ const valueFlags = new Set([
993
+ "-value",
994
+ "-encoding",
995
+ "-name",
996
+ "-itemtype",
997
+ "-filter",
998
+ "-include",
999
+ "-exclude",
1000
+ ]);
1001
+ const out: string[] = [];
1002
+ for (let i = 1; i < words.length; i++) {
1003
+ const word = words[i];
1004
+ const lower = word.toLowerCase();
1005
+ if (pathFlags.has(lower)) {
1006
+ if (i + 1 < words.length) out.push(words[++i]);
1007
+ continue;
1008
+ }
1009
+ if (valueFlags.has(lower)) {
1010
+ if (i + 1 < words.length) i++;
1011
+ continue;
1012
+ }
1013
+ if (isFlag(word)) continue;
1014
+ out.push(word);
1015
+ }
1016
+ return out;
1017
+ }
1018
+
1019
+ function looksSensitiveOperand(normalized: string): boolean {
1020
+ const name = normalized.split("/").pop()?.toLowerCase() ?? "";
1021
+ if (!name) return false;
1022
+ if (name === ".env" || (name.startsWith(".env.") && !/\.(example|sample|template|dist)$/.test(name)))
1023
+ return true;
1024
+ if (
1025
+ [
1026
+ ".npmrc",
1027
+ ".yarnrc",
1028
+ ".pypirc",
1029
+ ".netrc",
1030
+ "_netrc",
1031
+ ".git-credentials",
1032
+ ".authinfo",
1033
+ "auth.json",
1034
+ "credentials.json",
1035
+ "id_rsa",
1036
+ "id_ed25519",
1037
+ "id_ecdsa",
1038
+ ].includes(name)
1039
+ )
1040
+ return true;
1041
+ return /\.(pem|key|p12|pfx|jks|keystore|kdbx|tfstate|tfvars)$/i.test(name);
1042
+ }
1043
+
1044
+ // Keep the import useful for consumers that inspect redirects directly.
1045
+ export type { ShellRedirect };