@dieulc/workflow 0.11.0 → 0.11.1
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/README.md +83 -7
- package/commit-policy.ts +8 -0
- package/edit-policy.ts +12 -3
- package/folder-policy.ts +2165 -1159
- package/index.ts +644 -121
- package/package.json +2 -2
- package/read-policy-redact.ts +359 -319
- package/read-policy-tools.ts +3 -1
- package/read-policy.ts +26 -8
- package/review-runtime.ts +10 -0
- package/review-tools.ts +70 -11
- package/shell-paths.ts +29 -8
- package/utils.ts +98 -0
package/folder-policy.ts
CHANGED
|
@@ -1,1159 +1,2165 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workflow Extension — Shared Folder Policy
|
|
3
|
-
*
|
|
4
|
-
* One folder permission floor for every workflow policy:
|
|
5
|
-
*
|
|
6
|
-
* mode "repo" (default) — the nearest git repository root (fallback: cwd)
|
|
7
|
-
* mode "cwd" — exactly the session working directory
|
|
8
|
-
* mode "parent" — the parent of the repo/cwd base (siblings)
|
|
9
|
-
* mode "any" — no folder containment (sensitive files still blocked)
|
|
10
|
-
*
|
|
11
|
-
* `outside` decides what happens beyond the allowed roots:
|
|
12
|
-
*
|
|
13
|
-
* "ask" — prompt (allow once / always / deny); headless fails closed
|
|
14
|
-
* "block" — refuse without asking
|
|
15
|
-
*
|
|
16
|
-
* WHY THIS EXISTS: write and commit were the only policies, and each had its
|
|
17
|
-
* own path behaviour. `writePolicy: "allow"` could write anywhere; commit
|
|
18
|
-
* only inspected the git subcommand, so `git -C ../other commit` looked like a
|
|
19
|
-
* local commit. Reads were not gated at all. This module is the single floor
|
|
20
|
-
* all three consult. A policy may be stricter than this floor; it can never be
|
|
21
|
-
* looser.
|
|
22
|
-
*
|
|
23
|
-
* Sensitive files (`.env`, credentials, keys, cloud/ssh stores) are a
|
|
24
|
-
* capability-shared deny list. A folder grant never softens it: the sensitive
|
|
25
|
-
* check runs before containment and before any policy grant.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
export
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"**/
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"**/*.
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"
|
|
169
|
-
"
|
|
170
|
-
"
|
|
171
|
-
"**/.
|
|
172
|
-
"**/.
|
|
173
|
-
"**/.
|
|
174
|
-
"
|
|
175
|
-
"**/.
|
|
176
|
-
"**/.
|
|
177
|
-
"**/.
|
|
178
|
-
"**/.
|
|
179
|
-
"**/.
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
|
|
190
|
-
"**/.
|
|
191
|
-
"**/.
|
|
192
|
-
"**/.
|
|
193
|
-
"**/.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
function
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
)
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
:
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
*/
|
|
726
|
-
export function
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
)
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
)
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
);
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — Shared Folder Policy
|
|
3
|
+
*
|
|
4
|
+
* One folder permission floor for every workflow policy:
|
|
5
|
+
*
|
|
6
|
+
* mode "repo" (default) — the nearest git repository root (fallback: cwd)
|
|
7
|
+
* mode "cwd" — exactly the session working directory
|
|
8
|
+
* mode "parent" — the parent of the repo/cwd base (siblings)
|
|
9
|
+
* mode "any" — no folder containment (sensitive files still blocked)
|
|
10
|
+
*
|
|
11
|
+
* `outside` decides what happens beyond the allowed roots:
|
|
12
|
+
*
|
|
13
|
+
* "ask" — prompt (allow once / always / deny); headless fails closed
|
|
14
|
+
* "block" — refuse without asking
|
|
15
|
+
*
|
|
16
|
+
* WHY THIS EXISTS: write and commit were the only policies, and each had its
|
|
17
|
+
* own path behaviour. `writePolicy: "allow"` could write anywhere; commit
|
|
18
|
+
* only inspected the git subcommand, so `git -C ../other commit` looked like a
|
|
19
|
+
* local commit. Reads were not gated at all. This module is the single floor
|
|
20
|
+
* all three consult. A policy may be stricter than this floor; it can never be
|
|
21
|
+
* looser.
|
|
22
|
+
*
|
|
23
|
+
* Sensitive files (`.env`, credentials, keys, cloud/ssh stores) are a
|
|
24
|
+
* capability-shared deny list. A folder grant never softens it: the sensitive
|
|
25
|
+
* check runs before containment and before any policy grant.
|
|
26
|
+
*
|
|
27
|
+
* `excludePaths` is a different layer: an excluded folder is *scope
|
|
28
|
+
* subtraction*, not a secret. The excluded path itself is refused outright (no
|
|
29
|
+
* dialog, no grant); a path below it is "outside" and can still be requested
|
|
30
|
+
* one specific subfolder at a time, and an explicit capability grant inside
|
|
31
|
+
* the exclusion re-opens exactly that subtree. This is what lets a user deny a
|
|
32
|
+
* whole OS root or home directory without making their projects unreachable.
|
|
33
|
+
*
|
|
34
|
+
* Settings live under `workflow.folderPolicy` in `~/.pi/agent/settings.json`
|
|
35
|
+
* (global) or `<cwd>/.pi/settings.json` (project). Project settings are only
|
|
36
|
+
* honoured when the caller says the project is trusted. Writes preserve every
|
|
37
|
+
* other key in the settings file.
|
|
38
|
+
*
|
|
39
|
+
* This module holds the pure/I-O-light parts (config, path canonicalisation,
|
|
40
|
+
* repo-root detection, sensitive matching, the folder decision) so they can
|
|
41
|
+
* be unit-tested without a Pi runtime. Nothing here imports the Pi SDK.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as fs from "node:fs";
|
|
45
|
+
import * as os from "node:os";
|
|
46
|
+
import * as path from "node:path";
|
|
47
|
+
|
|
48
|
+
import type { ArgumentCompletion } from "./utils.ts";
|
|
49
|
+
import {
|
|
50
|
+
joinArgumentValue,
|
|
51
|
+
suppressExactArgumentMatch,
|
|
52
|
+
translateForeignPath,
|
|
53
|
+
translateMsysTempPath,
|
|
54
|
+
} from "./utils.ts";
|
|
55
|
+
import {
|
|
56
|
+
COMMIT_CONFIG_DIR,
|
|
57
|
+
WORKFLOW_SETTINGS_KEY,
|
|
58
|
+
type JsonObject,
|
|
59
|
+
} from "./commit-policy.ts";
|
|
60
|
+
|
|
61
|
+
/** Nested key under WORKFLOW_SETTINGS_KEY. */
|
|
62
|
+
export const FOLDER_POLICY_KEY = "folderPolicy";
|
|
63
|
+
|
|
64
|
+
/** Which base folder the floor is anchored to. */
|
|
65
|
+
export type FolderPolicyMode = "repo" | "cwd" | "parent" | "any";
|
|
66
|
+
|
|
67
|
+
/** What happens when a target is outside the allowed roots. */
|
|
68
|
+
export type FolderOutsideAction = "ask" | "block";
|
|
69
|
+
|
|
70
|
+
/** Where "Always allow" dialogs persist new grants. */
|
|
71
|
+
export type FolderGrantScope = "project" | "global";
|
|
72
|
+
|
|
73
|
+
/** Whether the default `.env.example`-style exemptions are active. */
|
|
74
|
+
export type FolderSensitiveTemplates = "allow" | "block";
|
|
75
|
+
|
|
76
|
+
export interface FolderPolicyConfig {
|
|
77
|
+
mode: FolderPolicyMode;
|
|
78
|
+
outside: FolderOutsideAction;
|
|
79
|
+
grantScope: FolderGrantScope;
|
|
80
|
+
sensitiveTemplates: FolderSensitiveTemplates;
|
|
81
|
+
/** Extra glob patterns denied in every policy. */
|
|
82
|
+
sensitivePatterns: string[];
|
|
83
|
+
/** Manual glob exemptions. No dialog writes this list. */
|
|
84
|
+
sensitiveExemptions: string[];
|
|
85
|
+
/**
|
|
86
|
+
* Folders the agent has no standing permission over (typically an OS root
|
|
87
|
+
* or the home directory). The excluded path itself is blocked without a
|
|
88
|
+
* dialog; descendants are outside the floor and need an explicit grant that
|
|
89
|
+
* sits inside the exclusion. See `evaluateFolderTargets` for the rescue
|
|
90
|
+
* rule. Sensitive-file rules still apply on top.
|
|
91
|
+
*/
|
|
92
|
+
excludePaths: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const DEFAULT_FOLDER_POLICY_CONFIG: Readonly<FolderPolicyConfig> = {
|
|
96
|
+
mode: "repo",
|
|
97
|
+
outside: "ask",
|
|
98
|
+
grantScope: "project",
|
|
99
|
+
sensitiveTemplates: "allow",
|
|
100
|
+
sensitivePatterns: [],
|
|
101
|
+
sensitiveExemptions: [],
|
|
102
|
+
excludePaths: [],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const FOLDER_POLICY_MODES: readonly FolderPolicyMode[] = [
|
|
106
|
+
"repo",
|
|
107
|
+
"cwd",
|
|
108
|
+
"parent",
|
|
109
|
+
"any",
|
|
110
|
+
];
|
|
111
|
+
const OUTSIDE_ACTIONS: readonly FolderOutsideAction[] = ["ask", "block"];
|
|
112
|
+
const GRANT_SCOPES: readonly FolderGrantScope[] = ["project", "global"];
|
|
113
|
+
const TEMPLATE_MODES: readonly FolderSensitiveTemplates[] = ["allow", "block"];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The default sensitive deny list. Patterns use forward slashes and are
|
|
117
|
+
* matched as path suffixes, so `**\/.env` matches `.env` at any depth.
|
|
118
|
+
* Directory rules are listed twice for the directory itself and descendants.
|
|
119
|
+
*/
|
|
120
|
+
export const DEFAULT_SENSITIVE_PATTERNS: readonly string[] = [
|
|
121
|
+
"**/.env",
|
|
122
|
+
"**/.env.*",
|
|
123
|
+
"**/.envrc",
|
|
124
|
+
"**/.envrc.*",
|
|
125
|
+
"**/.npmrc",
|
|
126
|
+
"**/.yarnrc",
|
|
127
|
+
"**/.yarnrc.yml",
|
|
128
|
+
"**/.pypirc",
|
|
129
|
+
"**/.netrc",
|
|
130
|
+
"**/_netrc",
|
|
131
|
+
"**/.git-credentials",
|
|
132
|
+
"**/.authinfo",
|
|
133
|
+
"**/.git/config",
|
|
134
|
+
"**/.git/credentials",
|
|
135
|
+
"**/.docker/config.json",
|
|
136
|
+
"**/auth.json",
|
|
137
|
+
"**/credentials.json",
|
|
138
|
+
"**/credentials.yml",
|
|
139
|
+
"**/credentials.yaml",
|
|
140
|
+
"**/secrets.json",
|
|
141
|
+
"**/secrets.yml",
|
|
142
|
+
"**/secrets.yaml",
|
|
143
|
+
"**/secrets.toml",
|
|
144
|
+
"**/secrets.ini",
|
|
145
|
+
"**/service-account*.json",
|
|
146
|
+
"**/*-credentials.json",
|
|
147
|
+
"**/*-service-account.json",
|
|
148
|
+
"**/id_rsa",
|
|
149
|
+
"**/id_dsa",
|
|
150
|
+
"**/id_ecdsa",
|
|
151
|
+
"**/id_ed25519",
|
|
152
|
+
"**/id_ecdsa_sk",
|
|
153
|
+
"**/id_ed25519_sk",
|
|
154
|
+
"**/*.pem",
|
|
155
|
+
"**/*.key",
|
|
156
|
+
"**/*.p12",
|
|
157
|
+
"**/*.pfx",
|
|
158
|
+
"**/*.jks",
|
|
159
|
+
"**/*.keystore",
|
|
160
|
+
"**/*.kdbx",
|
|
161
|
+
"**/*.kdb",
|
|
162
|
+
"**/*.ppk",
|
|
163
|
+
"**/*.p8",
|
|
164
|
+
"**/*.ovpn",
|
|
165
|
+
"**/*.mobileprovision",
|
|
166
|
+
"**/*.tfvars",
|
|
167
|
+
"**/*.tfstate",
|
|
168
|
+
"**/*.tfstate.backup",
|
|
169
|
+
"**/*.secret",
|
|
170
|
+
"**/*.secrets",
|
|
171
|
+
"**/.pgpass",
|
|
172
|
+
"**/.my.cnf",
|
|
173
|
+
"**/.htpasswd",
|
|
174
|
+
"**/*.jwk",
|
|
175
|
+
"**/.ssh",
|
|
176
|
+
"**/.ssh/**",
|
|
177
|
+
"**/.gnupg",
|
|
178
|
+
"**/.gnupg/**",
|
|
179
|
+
"**/.aws",
|
|
180
|
+
"**/.aws/**",
|
|
181
|
+
"**/.azure",
|
|
182
|
+
"**/.azure/**",
|
|
183
|
+
"**/.kube",
|
|
184
|
+
"**/.kube/**",
|
|
185
|
+
"**/.password-store",
|
|
186
|
+
"**/.password-store/**",
|
|
187
|
+
"**/.terraform",
|
|
188
|
+
"**/.terraform/**",
|
|
189
|
+
"**/.config/gh",
|
|
190
|
+
"**/.config/gh/**",
|
|
191
|
+
"**/.config/gcloud",
|
|
192
|
+
"**/.config/gcloud/**",
|
|
193
|
+
"**/.config/sops",
|
|
194
|
+
"**/.config/sops/**",
|
|
195
|
+
"**/.config/rclone",
|
|
196
|
+
"**/.config/rclone/**",
|
|
197
|
+
"**/Library/Keychains",
|
|
198
|
+
"**/Library/Keychains/**",
|
|
199
|
+
"**/AppData/Roaming/Microsoft/Credentials",
|
|
200
|
+
"**/AppData/Roaming/Microsoft/Credentials/**",
|
|
201
|
+
"**/AppData/Local/Microsoft/Credentials",
|
|
202
|
+
"**/AppData/Local/Microsoft/Credentials/**",
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
/** Templates that are safe by default; removed by `sensitiveTemplates: "block"`. */
|
|
206
|
+
export const DEFAULT_SENSITIVE_EXEMPTIONS: readonly string[] = [
|
|
207
|
+
"**/.env.example",
|
|
208
|
+
"**/.env.sample",
|
|
209
|
+
"**/.env.template",
|
|
210
|
+
"**/.env.dist",
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
/** A raw `workflow.folderPolicy` fragment (object or bare mode string). */
|
|
214
|
+
export type RawFolderPolicySetting = JsonObject | string;
|
|
215
|
+
|
|
216
|
+
function coerceEnum<T extends string>(
|
|
217
|
+
value: unknown,
|
|
218
|
+
allowed: readonly T[],
|
|
219
|
+
): T | undefined {
|
|
220
|
+
if (typeof value !== "string") return undefined;
|
|
221
|
+
const normalized = value.trim().toLowerCase();
|
|
222
|
+
return (allowed as readonly string[]).includes(normalized)
|
|
223
|
+
? (normalized as T)
|
|
224
|
+
: undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function coerceStringList(value: unknown): string[] | undefined {
|
|
228
|
+
if (!Array.isArray(value)) return undefined;
|
|
229
|
+
const out: string[] = [];
|
|
230
|
+
for (const item of value) {
|
|
231
|
+
if (typeof item !== "string") continue;
|
|
232
|
+
const trimmed = item.trim();
|
|
233
|
+
if (!trimmed || out.includes(trimmed)) continue;
|
|
234
|
+
out.push(trimmed);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function unionStrings(a: readonly string[], b: readonly string[]): string[] {
|
|
240
|
+
const out = [...a];
|
|
241
|
+
for (const item of b) if (!out.includes(item)) out.push(item);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Normalise one `excludePaths` entry. Entries stay globs (a bare name matches
|
|
247
|
+
* that folder at any depth), so this only fixes shell spellings: `~`
|
|
248
|
+
* expansion, Git-Bash/Cygwin/WSL translation, forward slashes, duplicate
|
|
249
|
+
* separators and a trailing separator except for a root. Case is left as
|
|
250
|
+
* written — the glob compiler matches case-insensitively.
|
|
251
|
+
*
|
|
252
|
+
* On Windows, `C:Personal` is drive-relative in a shell and, as a glob, would
|
|
253
|
+
* be a basename that never matches an absolute path. It is repaired to
|
|
254
|
+
* `C:/Personal` so a stored exclusion is never silently inert.
|
|
255
|
+
*/
|
|
256
|
+
export function normalizeExcludePattern(value: unknown): string {
|
|
257
|
+
if (typeof value !== "string") return "";
|
|
258
|
+
const trimmed = value.trim();
|
|
259
|
+
if (!trimmed) return "";
|
|
260
|
+
const foreign = translateForeignPath(expandTilde(trimmed));
|
|
261
|
+
let out = toPosixPath(stripWindowsPrefix(foreign.path || trimmed));
|
|
262
|
+
if (process.platform === "win32" && /^[A-Za-z]:(?![\\/])/.test(out)) {
|
|
263
|
+
out = `${out.slice(0, 2)}/${out.slice(2)}`;
|
|
264
|
+
}
|
|
265
|
+
if (out.startsWith("./")) out = out.slice(2);
|
|
266
|
+
const unc = out.startsWith("//");
|
|
267
|
+
out = out.replace(/\/{2,}/g, "/");
|
|
268
|
+
if (unc) out = `/${out}`;
|
|
269
|
+
while (
|
|
270
|
+
out.length > 1 &&
|
|
271
|
+
out.endsWith("/") &&
|
|
272
|
+
!/^[A-Za-z]:\/$/.test(out) &&
|
|
273
|
+
out !== "//"
|
|
274
|
+
) {
|
|
275
|
+
out = out.slice(0, -1);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* True when an exclusion pattern is an OS/drive/share root or the home
|
|
282
|
+
* directory.
|
|
283
|
+
*
|
|
284
|
+
* Deliberately narrower than `isBroadGrantRoot`, which refuses one-click
|
|
285
|
+
* *grants* of protected roots. An exclusion is a
|
|
286
|
+
* narrowing action, so excluding a specific top-level folder
|
|
287
|
+
* (`C:/Personal`) is a precise, normal request and must not be reported as a
|
|
288
|
+
* broad root. Only paths where "everything below is now unreachable" is the
|
|
289
|
+
* point — `/`, `C:/`, `//server/share`, `~` — get the note.
|
|
290
|
+
*/
|
|
291
|
+
export function isBroadExcludePattern(pattern: unknown): boolean {
|
|
292
|
+
const normalized = normalizeExcludePattern(pattern);
|
|
293
|
+
if (!normalized) return false;
|
|
294
|
+
const root = pathRootOf(normalized);
|
|
295
|
+
if (normalized.toLowerCase() === root.toLowerCase()) return true;
|
|
296
|
+
const home = normalizePolicyPath(os.homedir(), os.homedir());
|
|
297
|
+
return Boolean(home) && normalized.toLowerCase() === home;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Coerce a list of exclusion globs: normalise, drop empties, dedupe. */
|
|
301
|
+
function coerceExcludeList(value: unknown): string[] | undefined {
|
|
302
|
+
if (!Array.isArray(value)) return undefined;
|
|
303
|
+
return unionExcludePatterns([], value.map(normalizeExcludePattern));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Union exclusion globs. Dedupe ignores case because the glob compiler
|
|
308
|
+
* matches `excludePaths` case-insensitively (`globToRegExp`), so `C:/` and
|
|
309
|
+
* `c:/` are the same rule on every platform and across global/project scopes.
|
|
310
|
+
*/
|
|
311
|
+
function unionExcludePatterns(
|
|
312
|
+
a: readonly string[],
|
|
313
|
+
b: readonly string[],
|
|
314
|
+
): string[] {
|
|
315
|
+
const out: string[] = [];
|
|
316
|
+
const seen = new Set<string>();
|
|
317
|
+
for (const item of [...a, ...b]) {
|
|
318
|
+
const normalized = normalizeExcludePattern(item);
|
|
319
|
+
if (!normalized) continue;
|
|
320
|
+
const key = normalized.toLowerCase();
|
|
321
|
+
if (seen.has(key)) continue;
|
|
322
|
+
seen.add(key);
|
|
323
|
+
out.push(normalized);
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Coerce an untrusted fragment into a complete FolderPolicyConfig. */
|
|
329
|
+
export function coerceFolderPolicyConfig(
|
|
330
|
+
raw: unknown,
|
|
331
|
+
base: FolderPolicyConfig = DEFAULT_FOLDER_POLICY_CONFIG as FolderPolicyConfig,
|
|
332
|
+
): FolderPolicyConfig {
|
|
333
|
+
if (typeof raw === "string") {
|
|
334
|
+
const mode = coerceEnum(raw, FOLDER_POLICY_MODES);
|
|
335
|
+
return { ...base, mode: mode ?? base.mode };
|
|
336
|
+
}
|
|
337
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
338
|
+
return { ...base };
|
|
339
|
+
}
|
|
340
|
+
const obj = raw as JsonObject;
|
|
341
|
+
return {
|
|
342
|
+
mode: coerceEnum(obj.mode, FOLDER_POLICY_MODES) ?? base.mode,
|
|
343
|
+
outside: coerceEnum(obj.outside, OUTSIDE_ACTIONS) ?? base.outside,
|
|
344
|
+
grantScope: coerceEnum(obj.grantScope, GRANT_SCOPES) ?? base.grantScope,
|
|
345
|
+
sensitiveTemplates:
|
|
346
|
+
coerceEnum(obj.sensitiveTemplates, TEMPLATE_MODES) ??
|
|
347
|
+
base.sensitiveTemplates,
|
|
348
|
+
sensitivePatterns:
|
|
349
|
+
coerceStringList(obj.sensitivePatterns) ?? [...base.sensitivePatterns],
|
|
350
|
+
sensitiveExemptions:
|
|
351
|
+
coerceStringList(obj.sensitiveExemptions) ??
|
|
352
|
+
[...base.sensitiveExemptions],
|
|
353
|
+
excludePaths: coerceExcludeList(obj.excludePaths) ?? [...base.excludePaths],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Extract the `workflow.folderPolicy` fragment from a raw settings object. */
|
|
358
|
+
export function extractFolderPolicyRaw(
|
|
359
|
+
settings: unknown,
|
|
360
|
+
): RawFolderPolicySetting | undefined {
|
|
361
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
362
|
+
return undefined;
|
|
363
|
+
const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
|
|
364
|
+
if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
|
|
365
|
+
const raw = (wf as JsonObject)[FOLDER_POLICY_KEY];
|
|
366
|
+
if (typeof raw === "string") return raw;
|
|
367
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
368
|
+
return raw as JsonObject;
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Merge global then project fragments; scalars project-over-global, lists union. */
|
|
373
|
+
export function mergeFolderPolicyConfig(
|
|
374
|
+
globalRaw: unknown,
|
|
375
|
+
projectRaw: unknown,
|
|
376
|
+
): FolderPolicyConfig {
|
|
377
|
+
const fromGlobal = coerceFolderPolicyConfig(globalRaw);
|
|
378
|
+
const fromProject = coerceFolderPolicyConfig(projectRaw, fromGlobal);
|
|
379
|
+
return {
|
|
380
|
+
mode: fromProject.mode,
|
|
381
|
+
outside: fromProject.outside,
|
|
382
|
+
grantScope: fromProject.grantScope,
|
|
383
|
+
sensitiveTemplates: fromProject.sensitiveTemplates,
|
|
384
|
+
sensitivePatterns: unionStrings(
|
|
385
|
+
fromGlobal.sensitivePatterns,
|
|
386
|
+
fromProject.sensitivePatterns,
|
|
387
|
+
),
|
|
388
|
+
sensitiveExemptions: unionStrings(
|
|
389
|
+
fromGlobal.sensitiveExemptions,
|
|
390
|
+
fromProject.sensitiveExemptions,
|
|
391
|
+
),
|
|
392
|
+
// A project may add an exclusion, never remove a global one.
|
|
393
|
+
excludePaths: unionExcludePatterns(
|
|
394
|
+
fromGlobal.excludePaths,
|
|
395
|
+
fromProject.excludePaths,
|
|
396
|
+
),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface FolderPolicyConfigPaths {
|
|
401
|
+
global: string;
|
|
402
|
+
project: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* True when a tool target *is* one of the workflow settings files (the global
|
|
407
|
+
* settings or the project `.pi/settings.json`). Permission files are
|
|
408
|
+
* user-owned: the agent may not rewrite them, or it could delete its own
|
|
409
|
+
* exclusions. Slash commands write these files through fs and are unaffected.
|
|
410
|
+
*/
|
|
411
|
+
export function isWorkflowSettingsPath(
|
|
412
|
+
value: unknown,
|
|
413
|
+
cwd: string,
|
|
414
|
+
agentDir: string,
|
|
415
|
+
): boolean {
|
|
416
|
+
const paths = folderPolicyConfigPaths(cwd, agentDir);
|
|
417
|
+
const lexical = normalizePolicyPath(value, cwd);
|
|
418
|
+
if (!lexical) return false;
|
|
419
|
+
const canonical = canonicalizePolicyPath(lexical, cwd).path || lexical;
|
|
420
|
+
for (const candidate of [paths.global, paths.project]) {
|
|
421
|
+
const candidateLexical = normalizePolicyPath(candidate, cwd);
|
|
422
|
+
if (!candidateLexical) continue;
|
|
423
|
+
if (lexical === candidateLexical) return true;
|
|
424
|
+
const candidateCanonical =
|
|
425
|
+
canonicalizePolicyPath(candidateLexical, cwd).path || candidateLexical;
|
|
426
|
+
if (canonical === candidateCanonical) return true;
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function folderPolicyConfigPaths(
|
|
432
|
+
cwd: string,
|
|
433
|
+
agentDir: string,
|
|
434
|
+
): FolderPolicyConfigPaths {
|
|
435
|
+
const nativeCwd = translateForeignPath(cwd).path || cwd;
|
|
436
|
+
const nativeAgentDir = translateForeignPath(agentDir).path || agentDir;
|
|
437
|
+
return {
|
|
438
|
+
global: path.join(nativeAgentDir, "settings.json"),
|
|
439
|
+
project: path.join(nativeCwd, COMMIT_CONFIG_DIR, "settings.json"),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function readJsonSafe(fp: string): JsonObject | undefined {
|
|
444
|
+
try {
|
|
445
|
+
if (!fs.existsSync(fp)) return undefined;
|
|
446
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
447
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
448
|
+
return undefined;
|
|
449
|
+
return parsed as JsonObject;
|
|
450
|
+
} catch {
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Which settings scope supplied a scalar value. */
|
|
456
|
+
export type FolderPolicyScopeSource = "default" | "global" | "project";
|
|
457
|
+
|
|
458
|
+
export function folderPolicyModeSourceOf(
|
|
459
|
+
globalRaw: RawFolderPolicySetting | undefined,
|
|
460
|
+
projectRaw: RawFolderPolicySetting | undefined,
|
|
461
|
+
): FolderPolicyScopeSource {
|
|
462
|
+
const hasMode = (raw: unknown): boolean => {
|
|
463
|
+
if (typeof raw === "string")
|
|
464
|
+
return coerceEnum(raw, FOLDER_POLICY_MODES) !== undefined;
|
|
465
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
|
466
|
+
return (
|
|
467
|
+
coerceEnum((raw as JsonObject).mode, FOLDER_POLICY_MODES) !== undefined
|
|
468
|
+
);
|
|
469
|
+
};
|
|
470
|
+
if (hasMode(projectRaw)) return "project";
|
|
471
|
+
if (hasMode(globalRaw)) return "global";
|
|
472
|
+
return "default";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export interface FolderPolicyReadOptions {
|
|
476
|
+
/** Project settings are ignored unless this is explicitly true. */
|
|
477
|
+
trusted?: boolean;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export interface ResolvedFolderPolicy {
|
|
481
|
+
config: FolderPolicyConfig;
|
|
482
|
+
sources: {
|
|
483
|
+
global: boolean;
|
|
484
|
+
project: boolean;
|
|
485
|
+
/** True when a project fragment existed but was ignored (untrusted). */
|
|
486
|
+
projectIgnored: boolean;
|
|
487
|
+
mode: FolderPolicyScopeSource;
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* Present for spawned subagents: the parent-approved roots. When set, the
|
|
491
|
+
* caller must pass these to resolveFolderRoots and ignore per-policy grants.
|
|
492
|
+
*/
|
|
493
|
+
locked?: { roots: string[]; source: string };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function parseLockedRoots(raw: string | undefined): string[] | undefined {
|
|
497
|
+
if (!raw || !raw.trim()) return undefined;
|
|
498
|
+
try {
|
|
499
|
+
const parsed: unknown = JSON.parse(raw);
|
|
500
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
501
|
+
const out = parsed.filter(
|
|
502
|
+
(item): item is string => typeof item === "string" && item.trim() !== "",
|
|
503
|
+
);
|
|
504
|
+
return out.length > 0 ? out : undefined;
|
|
505
|
+
} catch {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Resolve the effective folder policy from disk. Never throws. Corrupt or
|
|
512
|
+
* missing files fall back to defaults. Project settings are ignored unless
|
|
513
|
+
* `trusted` is true. A spawned subagent with `PI_FOLDER_POLICY_LOCKED=1` gets
|
|
514
|
+
* the parent's roots and cannot read a more permissive project file.
|
|
515
|
+
*/
|
|
516
|
+
export function readFolderPolicyConfig(
|
|
517
|
+
cwd: string,
|
|
518
|
+
agentDir: string,
|
|
519
|
+
options: FolderPolicyReadOptions = {},
|
|
520
|
+
): ResolvedFolderPolicy {
|
|
521
|
+
const paths = folderPolicyConfigPaths(cwd, agentDir);
|
|
522
|
+
const globalSettings = readJsonSafe(paths.global);
|
|
523
|
+
const globalRaw = extractFolderPolicyRaw(globalSettings);
|
|
524
|
+
|
|
525
|
+
const lockedRoots =
|
|
526
|
+
process.env.PI_FOLDER_POLICY_LOCKED === "1"
|
|
527
|
+
? parseLockedRoots(process.env.PI_FOLDER_POLICY_ROOTS)
|
|
528
|
+
: undefined;
|
|
529
|
+
if (lockedRoots) {
|
|
530
|
+
// The child inherits the parent's deny layer (sensitive extras and
|
|
531
|
+
// exclusions); grants stay behind — scope comes from `lockedRoots`.
|
|
532
|
+
const deny = parseFolderPolicyDeny(process.env.PI_FOLDER_POLICY_DENY);
|
|
533
|
+
return {
|
|
534
|
+
config: {
|
|
535
|
+
...DEFAULT_FOLDER_POLICY_CONFIG,
|
|
536
|
+
mode: "cwd",
|
|
537
|
+
outside: "block",
|
|
538
|
+
sensitiveTemplates:
|
|
539
|
+
deny.sensitiveTemplates ?? DEFAULT_FOLDER_POLICY_CONFIG.sensitiveTemplates,
|
|
540
|
+
sensitivePatterns: deny.sensitivePatterns ?? [],
|
|
541
|
+
sensitiveExemptions: deny.sensitiveExemptions ?? [],
|
|
542
|
+
excludePaths: deny.excludePaths ?? [],
|
|
543
|
+
},
|
|
544
|
+
sources: {
|
|
545
|
+
global: globalRaw !== undefined,
|
|
546
|
+
project: false,
|
|
547
|
+
projectIgnored: true,
|
|
548
|
+
mode: "default",
|
|
549
|
+
},
|
|
550
|
+
locked: {
|
|
551
|
+
roots: lockedRoots,
|
|
552
|
+
source: process.env.PI_FOLDER_POLICY_SOURCE ?? "parent",
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const projectSettings = readJsonSafe(paths.project);
|
|
558
|
+
const projectRaw = extractFolderPolicyRaw(projectSettings);
|
|
559
|
+
const trusted = options.trusted === true;
|
|
560
|
+
const effectiveProjectRaw = trusted ? projectRaw : undefined;
|
|
561
|
+
return {
|
|
562
|
+
config: mergeFolderPolicyConfig(globalRaw, effectiveProjectRaw),
|
|
563
|
+
sources: {
|
|
564
|
+
global: globalRaw !== undefined,
|
|
565
|
+
project: projectRaw !== undefined,
|
|
566
|
+
projectIgnored: projectRaw !== undefined && !trusted,
|
|
567
|
+
mode: folderPolicyModeSourceOf(globalRaw, effectiveProjectRaw),
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Read-modify-write `workflow.folderPolicy`. Preserves every other settings
|
|
574
|
+
* key. Scalars are replaced; the two pattern lists are additive. Refuses to
|
|
575
|
+
* write while a subagent lock is active.
|
|
576
|
+
*/
|
|
577
|
+
export function writeFolderPolicyConfig(
|
|
578
|
+
scope: "global" | "project",
|
|
579
|
+
cwd: string,
|
|
580
|
+
agentDir: string,
|
|
581
|
+
patch: Partial<FolderPolicyConfig>,
|
|
582
|
+
): { path: string; ok: boolean; error?: string } {
|
|
583
|
+
const paths = folderPolicyConfigPaths(cwd, agentDir);
|
|
584
|
+
const fp = scope === "project" ? paths.project : paths.global;
|
|
585
|
+
if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
|
|
586
|
+
return {
|
|
587
|
+
path: fp,
|
|
588
|
+
ok: false,
|
|
589
|
+
error: "folder policy is locked for this subagent",
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const existing = readJsonSafe(fp);
|
|
594
|
+
const root: JsonObject = existing ? { ...existing } : {};
|
|
595
|
+
const wfExisting = root[WORKFLOW_SETTINGS_KEY];
|
|
596
|
+
const wf: JsonObject =
|
|
597
|
+
wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
|
|
598
|
+
? { ...(wfExisting as JsonObject) }
|
|
599
|
+
: {};
|
|
600
|
+
const current = coerceFolderPolicyConfig(wf[FOLDER_POLICY_KEY]);
|
|
601
|
+
const next: FolderPolicyConfig = {
|
|
602
|
+
mode: coerceEnum(patch.mode, FOLDER_POLICY_MODES) ?? current.mode,
|
|
603
|
+
outside:
|
|
604
|
+
coerceEnum(patch.outside, OUTSIDE_ACTIONS) ?? current.outside,
|
|
605
|
+
grantScope:
|
|
606
|
+
coerceEnum(patch.grantScope, GRANT_SCOPES) ?? current.grantScope,
|
|
607
|
+
sensitiveTemplates:
|
|
608
|
+
coerceEnum(patch.sensitiveTemplates, TEMPLATE_MODES) ??
|
|
609
|
+
current.sensitiveTemplates,
|
|
610
|
+
sensitivePatterns: unionStrings(
|
|
611
|
+
current.sensitivePatterns,
|
|
612
|
+
coerceStringList(patch.sensitivePatterns) ?? [],
|
|
613
|
+
),
|
|
614
|
+
sensitiveExemptions: unionStrings(
|
|
615
|
+
current.sensitiveExemptions,
|
|
616
|
+
coerceStringList(patch.sensitiveExemptions) ?? [],
|
|
617
|
+
),
|
|
618
|
+
excludePaths: unionExcludePatterns(
|
|
619
|
+
current.excludePaths,
|
|
620
|
+
coerceExcludeList(patch.excludePaths) ?? [],
|
|
621
|
+
),
|
|
622
|
+
};
|
|
623
|
+
wf[FOLDER_POLICY_KEY] = next;
|
|
624
|
+
root[WORKFLOW_SETTINGS_KEY] = wf;
|
|
625
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
626
|
+
fs.writeFileSync(fp, `${JSON.stringify(root, null, 2)}\n`, "utf8");
|
|
627
|
+
return { path: fp, ok: true };
|
|
628
|
+
} catch (error: unknown) {
|
|
629
|
+
return {
|
|
630
|
+
path: fp,
|
|
631
|
+
ok: false,
|
|
632
|
+
error: error instanceof Error ? error.message : String(error),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** The list-valued `folderPolicy` keys. */
|
|
638
|
+
export type FolderPolicyListKey =
|
|
639
|
+
| "sensitivePatterns"
|
|
640
|
+
| "sensitiveExemptions"
|
|
641
|
+
| "excludePaths";
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Remove exactly one entry from a `folderPolicy` list. This is the deliberate
|
|
645
|
+
* exception to the additive-write rule: a grant or a deny that the user cannot
|
|
646
|
+
* undo from the CLI is a trap, so `/folder-policy exclude revoke` needs a
|
|
647
|
+
* removal path that still preserves every other key in the file.
|
|
648
|
+
*/
|
|
649
|
+
export function revokeFolderPolicyListEntry(
|
|
650
|
+
scope: "global" | "project",
|
|
651
|
+
cwd: string,
|
|
652
|
+
agentDir: string,
|
|
653
|
+
key: FolderPolicyListKey,
|
|
654
|
+
value: string,
|
|
655
|
+
): { path: string; ok: boolean; removed: boolean; error?: string } {
|
|
656
|
+
const paths = folderPolicyConfigPaths(cwd, agentDir);
|
|
657
|
+
const fp = scope === "project" ? paths.project : paths.global;
|
|
658
|
+
if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
|
|
659
|
+
return {
|
|
660
|
+
path: fp,
|
|
661
|
+
ok: false,
|
|
662
|
+
removed: false,
|
|
663
|
+
error: "folder policy is locked for this subagent",
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const target =
|
|
667
|
+
key === "excludePaths"
|
|
668
|
+
? normalizeExcludePattern(value)
|
|
669
|
+
: typeof value === "string"
|
|
670
|
+
? value.trim()
|
|
671
|
+
: "";
|
|
672
|
+
if (!target) {
|
|
673
|
+
return { path: fp, ok: false, removed: false, error: "empty value" };
|
|
674
|
+
}
|
|
675
|
+
const same = (entry: string): boolean =>
|
|
676
|
+
key === "excludePaths"
|
|
677
|
+
? normalizeExcludePattern(entry).toLowerCase() === target.toLowerCase()
|
|
678
|
+
: entry.trim() === target;
|
|
679
|
+
try {
|
|
680
|
+
const existing = readJsonSafe(fp);
|
|
681
|
+
if (!existing) {
|
|
682
|
+
return { path: fp, ok: true, removed: false };
|
|
683
|
+
}
|
|
684
|
+
const root: JsonObject = { ...existing };
|
|
685
|
+
const wfExisting = root[WORKFLOW_SETTINGS_KEY];
|
|
686
|
+
if (
|
|
687
|
+
!wfExisting ||
|
|
688
|
+
typeof wfExisting !== "object" ||
|
|
689
|
+
Array.isArray(wfExisting)
|
|
690
|
+
) {
|
|
691
|
+
return { path: fp, ok: true, removed: false };
|
|
692
|
+
}
|
|
693
|
+
const wf: JsonObject = { ...(wfExisting as JsonObject) };
|
|
694
|
+
const current = coerceFolderPolicyConfig(wf[FOLDER_POLICY_KEY]);
|
|
695
|
+
const list = current[key];
|
|
696
|
+
const next = list.filter((entry) => !same(entry));
|
|
697
|
+
if (next.length === list.length) {
|
|
698
|
+
return { path: fp, ok: true, removed: false };
|
|
699
|
+
}
|
|
700
|
+
const nextConfig: FolderPolicyConfig = { ...current, [key]: next };
|
|
701
|
+
wf[FOLDER_POLICY_KEY] = nextConfig;
|
|
702
|
+
root[WORKFLOW_SETTINGS_KEY] = wf;
|
|
703
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
704
|
+
fs.writeFileSync(fp, `${JSON.stringify(root, null, 2)}\n`, "utf8");
|
|
705
|
+
return { path: fp, ok: true, removed: true };
|
|
706
|
+
} catch (error: unknown) {
|
|
707
|
+
return {
|
|
708
|
+
path: fp,
|
|
709
|
+
ok: false,
|
|
710
|
+
removed: false,
|
|
711
|
+
error: error instanceof Error ? error.message : String(error),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── Path canonicalisation ────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
function isCaseInsensitivePlatform(): boolean {
|
|
719
|
+
// Only Windows is case-insensitive by default. macOS APFS can be
|
|
720
|
+
// case-sensitive; lowercasing there merged distinct paths. realpath handles
|
|
721
|
+
// the actual filesystem identity.
|
|
722
|
+
return process.platform === "win32";
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Strip a Windows extended-length prefix from a realpath result. */
|
|
726
|
+
export function stripWindowsPrefix(value: string): string {
|
|
727
|
+
let out = value;
|
|
728
|
+
if (out.startsWith("\\\\?\\UNC\\")) out = `\\\\${out.slice(8)}`;
|
|
729
|
+
else if (out.startsWith("\\\\?\\")) out = out.slice(4);
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function toPosixPath(value: string): string {
|
|
734
|
+
return value.replace(/\\/g, "/");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Expand a leading `~` (the whole path or `~/` / `~\`). */
|
|
738
|
+
export function expandTilde(value: string): string {
|
|
739
|
+
if (value === "~") return os.homedir();
|
|
740
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
741
|
+
return path.join(os.homedir(), value.slice(2));
|
|
742
|
+
}
|
|
743
|
+
return value;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** The Pi read tool accepts an `@` prefix; strip it for path matching. */
|
|
747
|
+
export function stripAtPrefix(value: string): string {
|
|
748
|
+
return value.startsWith("@") ? value.slice(1) : value;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Canonical comparison form: absolute, forward slashes, platform case, no
|
|
753
|
+
* trailing separator (except a filesystem root). Empty input yields "".
|
|
754
|
+
*/
|
|
755
|
+
export function normalizePolicyPath(value: unknown, cwd: string): string {
|
|
756
|
+
if (typeof value !== "string") return "";
|
|
757
|
+
const raw = stripAtPrefix(expandTilde(value.trim()));
|
|
758
|
+
if (!raw) return "";
|
|
759
|
+
const foreign = translateForeignPath(raw);
|
|
760
|
+
const translated = foreign.path || raw;
|
|
761
|
+
const base = cwd && cwd.trim() ? cwd : process.cwd();
|
|
762
|
+
let resolved: string;
|
|
763
|
+
try {
|
|
764
|
+
resolved = path.resolve(base, translated);
|
|
765
|
+
} catch {
|
|
766
|
+
resolved = translated;
|
|
767
|
+
}
|
|
768
|
+
let normalized = toPosixPath(stripWindowsPrefix(resolved));
|
|
769
|
+
if (isCaseInsensitivePlatform()) normalized = normalized.toLowerCase();
|
|
770
|
+
while (
|
|
771
|
+
normalized.length > 1 &&
|
|
772
|
+
normalized.endsWith("/") &&
|
|
773
|
+
!/^[a-z]:\/$/.test(normalized) &&
|
|
774
|
+
normalized !== "//"
|
|
775
|
+
) {
|
|
776
|
+
normalized = normalized.slice(0, -1);
|
|
777
|
+
}
|
|
778
|
+
return normalized;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Resolve symlinks/junctions. If the target does not exist, resolve the
|
|
783
|
+
* nearest existing ancestor and append the remainder. `resolved` is false
|
|
784
|
+
* when no ancestor could be canonicalised (the caller should treat that as
|
|
785
|
+
* an uncertain boundary, not silently trust the lexical path).
|
|
786
|
+
*/
|
|
787
|
+
export function canonicalizePolicyPath(
|
|
788
|
+
value: unknown,
|
|
789
|
+
cwd: string,
|
|
790
|
+
): { path: string; resolved: boolean } {
|
|
791
|
+
const lexical = normalizePolicyPath(value, cwd);
|
|
792
|
+
if (!lexical) return { path: "", resolved: false };
|
|
793
|
+
const native = lexical.replace(/\//g, path.sep);
|
|
794
|
+
try {
|
|
795
|
+
const real = fs.realpathSync.native(native);
|
|
796
|
+
return { path: normalizePolicyPath(real, cwd), resolved: true };
|
|
797
|
+
} catch {
|
|
798
|
+
// Walk up to the nearest existing ancestor, then append the remainder.
|
|
799
|
+
const parts: string[] = [];
|
|
800
|
+
let current = native;
|
|
801
|
+
while (true) {
|
|
802
|
+
try {
|
|
803
|
+
const real = fs.realpathSync.native(current);
|
|
804
|
+
const joined = path.join(real, ...parts.reverse());
|
|
805
|
+
return { path: normalizePolicyPath(joined, cwd), resolved: true };
|
|
806
|
+
} catch {
|
|
807
|
+
const parent = path.dirname(current);
|
|
808
|
+
if (parent === current) break;
|
|
809
|
+
parts.push(path.basename(current));
|
|
810
|
+
current = parent;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return { path: lexical, resolved: false };
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Compare two already-normalised paths on the current platform. */
|
|
818
|
+
export function samePolicyPath(a: string, b: string): boolean {
|
|
819
|
+
return a === b;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** True when `target` equals `root` or sits strictly inside it. */
|
|
823
|
+
export function isInsidePolicyPath(target: string, root: string): boolean {
|
|
824
|
+
if (!target || !root) return false;
|
|
825
|
+
if (target === root) return true;
|
|
826
|
+
const rel = toPosixPath(path.relative(root, target));
|
|
827
|
+
if (!rel) return true;
|
|
828
|
+
return rel !== ".." && !rel.startsWith("../") && !path.isAbsolute(rel);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** True when the target is inside at least one root. */
|
|
832
|
+
export function isInsideAnyRoot(
|
|
833
|
+
target: string,
|
|
834
|
+
roots: readonly string[],
|
|
835
|
+
): boolean {
|
|
836
|
+
return roots.some((root) => isInsidePolicyPath(target, root));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Walk up to the nearest `.git` directory or worktree `.git` file. Returns
|
|
841
|
+
* the canonical repository root, or null when cwd is not in a repository.
|
|
842
|
+
*/
|
|
843
|
+
export function detectRepoRoot(cwd: string): string | null {
|
|
844
|
+
let current = normalizePolicyPath(cwd, process.cwd());
|
|
845
|
+
if (!current) return null;
|
|
846
|
+
while (true) {
|
|
847
|
+
const marker = current.replace(/\//g, path.sep);
|
|
848
|
+
try {
|
|
849
|
+
if (fs.existsSync(path.join(marker, ".git"))) {
|
|
850
|
+
const canonical = canonicalizePolicyPath(current, cwd);
|
|
851
|
+
return canonical.path || current;
|
|
852
|
+
}
|
|
853
|
+
} catch {
|
|
854
|
+
/* keep walking */
|
|
855
|
+
}
|
|
856
|
+
const parent = path.dirname(marker);
|
|
857
|
+
if (parent === marker) return null;
|
|
858
|
+
current = normalizePolicyPath(parent, cwd);
|
|
859
|
+
if (!current) return null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** The base folder for a mode (before grants). */
|
|
864
|
+
export function baseFolderRoots(
|
|
865
|
+
config: FolderPolicyConfig,
|
|
866
|
+
cwd: string,
|
|
867
|
+
): string[] {
|
|
868
|
+
const working = normalizePolicyPath(cwd, cwd);
|
|
869
|
+
const repoBase = detectRepoRoot(cwd) ?? working;
|
|
870
|
+
switch (config.mode) {
|
|
871
|
+
case "repo":
|
|
872
|
+
return [repoBase];
|
|
873
|
+
case "cwd":
|
|
874
|
+
return [working];
|
|
875
|
+
case "parent": {
|
|
876
|
+
const parent = path.dirname(repoBase.replace(/\//g, path.sep));
|
|
877
|
+
return [normalizePolicyPath(parent, cwd)];
|
|
878
|
+
}
|
|
879
|
+
case "any":
|
|
880
|
+
default:
|
|
881
|
+
return [];
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** A set of roots kept in both comparison forms. */
|
|
886
|
+
export interface FolderRootSet {
|
|
887
|
+
lexical: string[];
|
|
888
|
+
canonical: string[];
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export interface ResolvedFolderRoots {
|
|
892
|
+
lexical: string[];
|
|
893
|
+
canonical: string[];
|
|
894
|
+
unrestricted: boolean;
|
|
895
|
+
/** Roots by origin, so the exclusion rescue rule knows what is explicit. */
|
|
896
|
+
base: FolderRootSet;
|
|
897
|
+
/** The session working directory (implicit). */
|
|
898
|
+
anchor: FolderRootSet;
|
|
899
|
+
/** Capability grants and approved "allow once" roots (explicit). */
|
|
900
|
+
explicit: FolderRootSet;
|
|
901
|
+
/** Parent-approved roots for a locked subagent (implicit). */
|
|
902
|
+
locked: FolderRootSet;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Normalise + canonicalise a root list into both comparison forms. */
|
|
906
|
+
function buildRootSet(
|
|
907
|
+
entries: readonly string[],
|
|
908
|
+
cwd: string,
|
|
909
|
+
): FolderRootSet {
|
|
910
|
+
const lexical: string[] = [];
|
|
911
|
+
const canonical: string[] = [];
|
|
912
|
+
for (const entry of entries) {
|
|
913
|
+
const normalized = normalizePolicyPath(entry, cwd);
|
|
914
|
+
if (!normalized) continue;
|
|
915
|
+
if (!lexical.includes(normalized)) lexical.push(normalized);
|
|
916
|
+
const resolved = canonicalizePolicyPath(normalized, cwd);
|
|
917
|
+
if (resolved.path && !canonical.includes(resolved.path))
|
|
918
|
+
canonical.push(resolved.path);
|
|
919
|
+
}
|
|
920
|
+
return { lexical, canonical };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function mergeRootSets(...sets: readonly FolderRootSet[]): FolderRootSet {
|
|
924
|
+
const lexical: string[] = [];
|
|
925
|
+
const canonical: string[] = [];
|
|
926
|
+
for (const set of sets) {
|
|
927
|
+
for (const entry of set.lexical)
|
|
928
|
+
if (!lexical.includes(entry)) lexical.push(entry);
|
|
929
|
+
for (const entry of set.canonical)
|
|
930
|
+
if (!canonical.includes(entry)) canonical.push(entry);
|
|
931
|
+
}
|
|
932
|
+
return { lexical, canonical };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The deny layer a parent passes to a locked child
|
|
937
|
+
* (`PI_FOLDER_POLICY_DENY`). Lists only ever tighten: a child inherits the
|
|
938
|
+
* parent's sensitive extras and exclusions, never its grants (the child's
|
|
939
|
+
* scope comes from `PI_FOLDER_POLICY_ROOTS`).
|
|
940
|
+
*/
|
|
941
|
+
export interface FolderPolicyDenyPayload {
|
|
942
|
+
sensitiveTemplates?: FolderSensitiveTemplates;
|
|
943
|
+
sensitivePatterns?: string[];
|
|
944
|
+
sensitiveExemptions?: string[];
|
|
945
|
+
excludePaths?: string[];
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Parse `PI_FOLDER_POLICY_DENY`; anything malformed yields an empty layer. */
|
|
949
|
+
export function parseFolderPolicyDeny(
|
|
950
|
+
raw: string | undefined,
|
|
951
|
+
): FolderPolicyDenyPayload {
|
|
952
|
+
if (!raw || !raw.trim()) return {};
|
|
953
|
+
let parsed: unknown;
|
|
954
|
+
try {
|
|
955
|
+
parsed = JSON.parse(raw);
|
|
956
|
+
} catch {
|
|
957
|
+
return {};
|
|
958
|
+
}
|
|
959
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
960
|
+
const obj = parsed as JsonObject;
|
|
961
|
+
const payload: FolderPolicyDenyPayload = {};
|
|
962
|
+
const templates = coerceEnum(obj.sensitiveTemplates, TEMPLATE_MODES);
|
|
963
|
+
if (templates) payload.sensitiveTemplates = templates;
|
|
964
|
+
const patterns = coerceStringList(obj.sensitivePatterns);
|
|
965
|
+
if (patterns) payload.sensitivePatterns = patterns;
|
|
966
|
+
const exemptions = coerceStringList(obj.sensitiveExemptions);
|
|
967
|
+
if (exemptions) payload.sensitiveExemptions = exemptions;
|
|
968
|
+
const excludes = coerceExcludeList(obj.excludePaths);
|
|
969
|
+
if (excludes) payload.excludePaths = excludes;
|
|
970
|
+
return payload;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Serialise a parent's deny layer for the child environment. */
|
|
974
|
+
export function buildFolderPolicyDeny(
|
|
975
|
+
config: FolderPolicyConfig,
|
|
976
|
+
): string {
|
|
977
|
+
return JSON.stringify({
|
|
978
|
+
sensitiveTemplates: config.sensitiveTemplates,
|
|
979
|
+
sensitivePatterns: config.sensitivePatterns,
|
|
980
|
+
sensitiveExemptions: config.sensitiveExemptions,
|
|
981
|
+
excludePaths: config.excludePaths,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Turn a policy + per-capability grants into comparable roots. When
|
|
987
|
+
* `lockedRoots` is supplied (spawned subagent), it replaces the base and the
|
|
988
|
+
* grants entirely: the child only gets what the parent approved.
|
|
989
|
+
*/
|
|
990
|
+
export function resolveFolderRoots(
|
|
991
|
+
config: FolderPolicyConfig,
|
|
992
|
+
grants: readonly string[],
|
|
993
|
+
cwd: string,
|
|
994
|
+
lockedRoots?: readonly string[],
|
|
995
|
+
): ResolvedFolderRoots {
|
|
996
|
+
const locked = lockedRoots && lockedRoots.length > 0;
|
|
997
|
+
const base = locked
|
|
998
|
+
? { lexical: [], canonical: [] }
|
|
999
|
+
: buildRootSet(baseFolderRoots(config, cwd), cwd);
|
|
1000
|
+
// A grant typed as `/tmp` means the Git-Bash temp dir on Windows; the
|
|
1001
|
+
// dialog already stores the translated native path, but a manual
|
|
1002
|
+
// `/read-policy allow /tmp` should behave the same.
|
|
1003
|
+
const explicitGrants = locked
|
|
1004
|
+
? grants
|
|
1005
|
+
: grants.map((grant) => translateMsysTempPath(grant, { cwd }));
|
|
1006
|
+
const explicit = locked
|
|
1007
|
+
? { lexical: [], canonical: [] }
|
|
1008
|
+
: buildRootSet(explicitGrants, cwd);
|
|
1009
|
+
const lockedSet = locked
|
|
1010
|
+
? buildRootSet(lockedRoots ?? [], cwd)
|
|
1011
|
+
: { lexical: [], canonical: [] };
|
|
1012
|
+
const anchor = buildRootSet([cwd], cwd);
|
|
1013
|
+
const effective = mergeRootSets(base, explicit, lockedSet);
|
|
1014
|
+
return {
|
|
1015
|
+
lexical: effective.lexical,
|
|
1016
|
+
canonical: effective.canonical,
|
|
1017
|
+
unrestricted: config.mode === "any" && !locked,
|
|
1018
|
+
base,
|
|
1019
|
+
anchor,
|
|
1020
|
+
explicit,
|
|
1021
|
+
locked: lockedSet,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* The folder a persistent "Always allow" grant should add for a target:
|
|
1027
|
+
* a directory targets itself, a file targets its containing directory, and a
|
|
1028
|
+
* glob targets its static prefix. Broad-root refusal is the caller's job.
|
|
1029
|
+
*/
|
|
1030
|
+
export function grantRootForTarget(target: FolderTarget): string {
|
|
1031
|
+
const value = target.path;
|
|
1032
|
+
if (!value) return "";
|
|
1033
|
+
const wildcard = value.search(/[?*[]/);
|
|
1034
|
+
if (wildcard >= 0) {
|
|
1035
|
+
const prefix = value.slice(0, wildcard).replace(/\/+$/, "");
|
|
1036
|
+
return prefix || value;
|
|
1037
|
+
}
|
|
1038
|
+
if (target.kind === "dir" || target.kind === "repo") return value;
|
|
1039
|
+
return toPosixPath(path.dirname(value.replace(/\//g, path.sep)));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* The “outside project” folder a grant should cover: the first path segment
|
|
1044
|
+
* below the deepest common ancestor of the base and the target. A file that
|
|
1045
|
+
* sits directly in that common ancestor is granted as the file itself; an
|
|
1046
|
+
* outside target that is an ancestor of the base is granted as itself.
|
|
1047
|
+
*
|
|
1048
|
+
* Returns undefined for a target inside the base (the caller keeps its
|
|
1049
|
+
* capability-specific grant, e.g. an exact write path).
|
|
1050
|
+
*/
|
|
1051
|
+
export function outsideProjectRootForTarget(
|
|
1052
|
+
target: FolderTarget,
|
|
1053
|
+
baseRoot: string,
|
|
1054
|
+
): string | undefined {
|
|
1055
|
+
const base = normalizePolicyPath(baseRoot, baseRoot);
|
|
1056
|
+
const normalized = normalizePolicyPath(target.path, base || process.cwd());
|
|
1057
|
+
if (!base || !normalized) return undefined;
|
|
1058
|
+
if (isInsidePolicyPath(normalized, base)) return undefined;
|
|
1059
|
+
// A repository root above a cwd-mode session is itself the outside folder.
|
|
1060
|
+
if (isInsidePolicyPath(base, normalized)) return normalized;
|
|
1061
|
+
|
|
1062
|
+
const baseParts = base.split("/").filter(Boolean);
|
|
1063
|
+
const targetParts = normalized.split("/").filter(Boolean);
|
|
1064
|
+
let common = 0;
|
|
1065
|
+
while (
|
|
1066
|
+
common < baseParts.length &&
|
|
1067
|
+
common < targetParts.length &&
|
|
1068
|
+
baseParts[common] === targetParts[common]
|
|
1069
|
+
) {
|
|
1070
|
+
common++;
|
|
1071
|
+
}
|
|
1072
|
+
const commonRoot = baseParts.slice(0, common).join("/");
|
|
1073
|
+
const remaining = targetParts.slice(common);
|
|
1074
|
+
if (remaining.length === 0) return normalized;
|
|
1075
|
+
// Directly in the common ancestor: a file stays the file; a directory is
|
|
1076
|
+
// the directory; a glob/static prefix stays its prefix.
|
|
1077
|
+
if (remaining.length === 1) return normalized;
|
|
1078
|
+
const child = remaining[0];
|
|
1079
|
+
return commonRoot ? `${commonRoot}/${child}` : `/${child}`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Folders a dialog must not grant wholesale: home, and the system/profile
|
|
1084
|
+
* umbrellas below it. The filesystem/drive root and home's ancestors are
|
|
1085
|
+
* handled in `broadGrantRootReason` (they depend on the candidate itself).
|
|
1086
|
+
*
|
|
1087
|
+
* The "contains" direction is deliberate: granting `C:/Windows` or
|
|
1088
|
+
* `AppData/Local` is broad, while a concrete folder inside one of them
|
|
1089
|
+
* (`C:/Windows/Temp`, `AppData/Local/Temp`, `/etc/ssl/certs`) is grantable.
|
|
1090
|
+
* The OS temp dir is intentionally absent.
|
|
1091
|
+
*/
|
|
1092
|
+
function addProtectedRoot(
|
|
1093
|
+
out: Set<string>,
|
|
1094
|
+
value: string | undefined,
|
|
1095
|
+
cwd: string,
|
|
1096
|
+
): void {
|
|
1097
|
+
if (!value) return;
|
|
1098
|
+
const normalized = normalizePolicyPath(value, cwd);
|
|
1099
|
+
if (normalized) out.add(normalized);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
let protectedRootsCache: { key: string; roots: string[] } | undefined;
|
|
1103
|
+
|
|
1104
|
+
function protectedRootsCacheKey(cwd: string): string {
|
|
1105
|
+
return [
|
|
1106
|
+
process.platform,
|
|
1107
|
+
cwd,
|
|
1108
|
+
os.homedir(),
|
|
1109
|
+
process.env.SystemRoot ?? "",
|
|
1110
|
+
process.env.windir ?? "",
|
|
1111
|
+
process.env.ProgramFiles ?? "",
|
|
1112
|
+
process.env["ProgramFiles(x86)"] ?? "",
|
|
1113
|
+
process.env.ProgramData ?? "",
|
|
1114
|
+
process.env.ALLUSERSPROFILE ?? "",
|
|
1115
|
+
process.env.PUBLIC ?? "",
|
|
1116
|
+
].join("\0");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** The protected system/profile umbrellas for this platform. Exported for tests. */
|
|
1120
|
+
export function protectedGrantRoots(cwd: string): string[] {
|
|
1121
|
+
const key = protectedRootsCacheKey(cwd);
|
|
1122
|
+
if (protectedRootsCache?.key === key) return protectedRootsCache.roots;
|
|
1123
|
+
const out = new Set<string>();
|
|
1124
|
+
addProtectedRoot(out, os.homedir(), cwd);
|
|
1125
|
+
if (process.platform === "win32") {
|
|
1126
|
+
const systemRoot =
|
|
1127
|
+
process.env.SystemRoot || process.env.windir || "C:/Windows";
|
|
1128
|
+
addProtectedRoot(out, systemRoot, cwd);
|
|
1129
|
+
addProtectedRoot(out, path.join(systemRoot, "System32"), cwd);
|
|
1130
|
+
addProtectedRoot(out, path.join(systemRoot, "SysWOW64"), cwd);
|
|
1131
|
+
addProtectedRoot(out, process.env.ProgramFiles || "C:/Program Files", cwd);
|
|
1132
|
+
addProtectedRoot(out, process.env["ProgramFiles(x86)"], cwd);
|
|
1133
|
+
addProtectedRoot(
|
|
1134
|
+
out,
|
|
1135
|
+
process.env.ProgramData || process.env.ALLUSERSPROFILE || "C:/ProgramData",
|
|
1136
|
+
cwd,
|
|
1137
|
+
);
|
|
1138
|
+
addProtectedRoot(out, process.env.ALLUSERSPROFILE, cwd);
|
|
1139
|
+
addProtectedRoot(out, process.env.PUBLIC, cwd);
|
|
1140
|
+
const home = os.homedir();
|
|
1141
|
+
addProtectedRoot(out, path.join(home, "AppData"), cwd);
|
|
1142
|
+
addProtectedRoot(out, path.join(home, "AppData", "Local"), cwd);
|
|
1143
|
+
addProtectedRoot(out, path.join(home, "AppData", "Roaming"), cwd);
|
|
1144
|
+
addProtectedRoot(out, path.join(home, "AppData", "LocalLow"), cwd);
|
|
1145
|
+
const driveRoot = path.parse(
|
|
1146
|
+
normalizePolicyPath(home, cwd) || home,
|
|
1147
|
+
).root;
|
|
1148
|
+
addProtectedRoot(out, path.join(driveRoot, "$Recycle.Bin"), cwd);
|
|
1149
|
+
addProtectedRoot(out, path.join(driveRoot, "System Volume Information"), cwd);
|
|
1150
|
+
addProtectedRoot(out, path.join(driveRoot, "Recovery"), cwd);
|
|
1151
|
+
} else {
|
|
1152
|
+
for (const root of [
|
|
1153
|
+
"/etc",
|
|
1154
|
+
"/usr",
|
|
1155
|
+
"/bin",
|
|
1156
|
+
"/sbin",
|
|
1157
|
+
"/lib",
|
|
1158
|
+
"/lib64",
|
|
1159
|
+
"/boot",
|
|
1160
|
+
"/dev",
|
|
1161
|
+
"/proc",
|
|
1162
|
+
"/sys",
|
|
1163
|
+
"/var",
|
|
1164
|
+
"/opt",
|
|
1165
|
+
"/srv",
|
|
1166
|
+
"/mnt",
|
|
1167
|
+
"/media",
|
|
1168
|
+
"/root",
|
|
1169
|
+
"/home",
|
|
1170
|
+
"/Users",
|
|
1171
|
+
"/System",
|
|
1172
|
+
"/Library",
|
|
1173
|
+
"/Applications",
|
|
1174
|
+
"/private",
|
|
1175
|
+
"/Volumes",
|
|
1176
|
+
]) {
|
|
1177
|
+
addProtectedRoot(out, root, cwd);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const roots = [...out].sort((a, b) => a.localeCompare(b));
|
|
1181
|
+
protectedRootsCache = { key, roots };
|
|
1182
|
+
return roots;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Why a persistent grant of this folder is too broad, or undefined when it is
|
|
1187
|
+
* grantable. Broad means the filesystem/drive root, home or an ancestor of
|
|
1188
|
+
* home, or a folder that equals/contains a protected system umbrellas.
|
|
1189
|
+
*/
|
|
1190
|
+
export function broadGrantRootReason(
|
|
1191
|
+
value: string,
|
|
1192
|
+
cwd = process.cwd(),
|
|
1193
|
+
): string | undefined {
|
|
1194
|
+
const normalized = normalizePolicyPath(value, cwd);
|
|
1195
|
+
if (!normalized) return "an empty path";
|
|
1196
|
+
const root = normalizePolicyPath(path.parse(normalized).root, cwd);
|
|
1197
|
+
if (normalized === root) return "the filesystem root";
|
|
1198
|
+
const home = normalizePolicyPath(os.homedir(), cwd);
|
|
1199
|
+
if (home) {
|
|
1200
|
+
if (normalized === home) return "your home folder";
|
|
1201
|
+
if (isInsidePolicyPath(home, normalized))
|
|
1202
|
+
return "a parent folder of your home folder";
|
|
1203
|
+
}
|
|
1204
|
+
for (const protectedRoot of protectedGrantRoots(cwd)) {
|
|
1205
|
+
if (normalized === protectedRoot)
|
|
1206
|
+
return `a protected system folder \`${protectedRoot}\``;
|
|
1207
|
+
if (isInsidePolicyPath(protectedRoot, normalized))
|
|
1208
|
+
return `a folder that contains the protected system folder \`${protectedRoot}\``;
|
|
1209
|
+
}
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* A broad grant is refused a one-click "Always allow". Depth is not the test:
|
|
1215
|
+
* a concrete top-level folder (`C:/tmp`, `D:/work`, `/tmp`) is grantable; a
|
|
1216
|
+
* root, home, or a system/profile umbrella is not.
|
|
1217
|
+
*/
|
|
1218
|
+
export function isBroadGrantRoot(
|
|
1219
|
+
value: string,
|
|
1220
|
+
cwd = process.cwd(),
|
|
1221
|
+
): boolean {
|
|
1222
|
+
return broadGrantRootReason(value, cwd) !== undefined;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* The folder a dialog's "Always allow" should persist for one target.
|
|
1227
|
+
*
|
|
1228
|
+
* Starts from the outside-project grouping, then: an excluded subtree keeps
|
|
1229
|
+
* the target's own folder (a grant above the exclusion rescues nothing), and
|
|
1230
|
+
* a grouped root that is broad falls back to the target's own folder. If the
|
|
1231
|
+
* own folder is broad too, `broad` carries the reason and the caller must not
|
|
1232
|
+
* offer a persistent grant.
|
|
1233
|
+
*/
|
|
1234
|
+
export function dialogGrantRootForTarget(
|
|
1235
|
+
target: FolderTarget,
|
|
1236
|
+
baseRoot: string | undefined,
|
|
1237
|
+
cwd: string,
|
|
1238
|
+
): { root: string; broad: string | undefined } {
|
|
1239
|
+
const own = grantRootForTarget(target);
|
|
1240
|
+
const ownResult = () => ({
|
|
1241
|
+
root: own,
|
|
1242
|
+
broad: broadGrantRootReason(own, cwd),
|
|
1243
|
+
});
|
|
1244
|
+
const outside = baseRoot
|
|
1245
|
+
? outsideProjectRootForTarget(target, baseRoot)
|
|
1246
|
+
: undefined;
|
|
1247
|
+
if (!outside) return ownResult();
|
|
1248
|
+
const excludeRoot = target.excludeRoot;
|
|
1249
|
+
if (
|
|
1250
|
+
excludeRoot &&
|
|
1251
|
+
!isInsidePolicyPath(
|
|
1252
|
+
normalizePolicyPath(outside, cwd),
|
|
1253
|
+
normalizePolicyPath(excludeRoot, cwd),
|
|
1254
|
+
)
|
|
1255
|
+
) {
|
|
1256
|
+
return ownResult();
|
|
1257
|
+
}
|
|
1258
|
+
if (broadGrantRootReason(outside, cwd)) return ownResult();
|
|
1259
|
+
return { root: outside, broad: undefined };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** UNC / network share detection (Windows and POSIX). */
|
|
1263
|
+
export function isNetworkPath(value: string): boolean {
|
|
1264
|
+
const normalized = toPosixPath(value);
|
|
1265
|
+
if (normalized.startsWith("//")) return true;
|
|
1266
|
+
if (/^\/net\//.test(normalized)) return true;
|
|
1267
|
+
if (/^\\\\[^\\]/.test(value)) return true;
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// ── Sensitive matching ───────────────────────────────────────────────
|
|
1272
|
+
|
|
1273
|
+
function escapeRegExp(value: string): string {
|
|
1274
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function globCoreToRegExp(pattern: string): string {
|
|
1278
|
+
let out = "";
|
|
1279
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
1280
|
+
const ch = pattern[i];
|
|
1281
|
+
if (ch === "*") {
|
|
1282
|
+
if (pattern[i + 1] === "*") {
|
|
1283
|
+
i++;
|
|
1284
|
+
if (pattern[i + 1] === "/") {
|
|
1285
|
+
i++;
|
|
1286
|
+
out += "(?:.*/)?";
|
|
1287
|
+
} else out += ".*";
|
|
1288
|
+
} else out += "[^/]*";
|
|
1289
|
+
} else if (ch === "?") out += "[^/]";
|
|
1290
|
+
else out += escapeRegExp(ch);
|
|
1291
|
+
}
|
|
1292
|
+
return out;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** Compile one sensitive glob. Patterns are matched as path suffixes. */
|
|
1296
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
1297
|
+
let source = toPosixPath(pattern.trim());
|
|
1298
|
+
if (source.startsWith("./")) source = source.slice(2);
|
|
1299
|
+
if (!source) return /$^/;
|
|
1300
|
+
if (!source.includes("/")) {
|
|
1301
|
+
return new RegExp(`(?:^|/)${globCoreToRegExp(source)}$`, "i");
|
|
1302
|
+
}
|
|
1303
|
+
if (source.startsWith("**/")) source = source.slice(3);
|
|
1304
|
+
if (source.endsWith("/**")) {
|
|
1305
|
+
const head = source.slice(0, -3);
|
|
1306
|
+
return new RegExp(`(?:^|/)${globCoreToRegExp(head)}(?:/.*)?$`, "i");
|
|
1307
|
+
}
|
|
1308
|
+
return new RegExp(`(?:^|/)${globCoreToRegExp(source)}$`, "i");
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
export interface SensitiveMatch {
|
|
1312
|
+
sensitive: boolean;
|
|
1313
|
+
pattern?: string;
|
|
1314
|
+
path?: string;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function patternMatches(
|
|
1318
|
+
pattern: string,
|
|
1319
|
+
lexical: string,
|
|
1320
|
+
canonical: string,
|
|
1321
|
+
cwd: string,
|
|
1322
|
+
): boolean {
|
|
1323
|
+
const re = globToRegExp(pattern);
|
|
1324
|
+
const rel = toPosixPath(path.relative(cwd, lexical));
|
|
1325
|
+
const candidates = [lexical, canonical, rel];
|
|
1326
|
+
return candidates.some((candidate) => candidate && re.test(candidate));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** The effective deny patterns (defaults + extra, exemptions applied later). */
|
|
1330
|
+
export function effectiveSensitivePatterns(
|
|
1331
|
+
config: FolderPolicyConfig,
|
|
1332
|
+
): string[] {
|
|
1333
|
+
return unionStrings(DEFAULT_SENSITIVE_PATTERNS, config.sensitivePatterns);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** The effective exemptions (default templates + manual exemptions). */
|
|
1337
|
+
export function effectiveSensitiveExemptions(
|
|
1338
|
+
config: FolderPolicyConfig,
|
|
1339
|
+
): string[] {
|
|
1340
|
+
const templates =
|
|
1341
|
+
config.sensitiveTemplates === "allow" ? [...DEFAULT_SENSITIVE_EXEMPTIONS] : [];
|
|
1342
|
+
return unionStrings(templates, config.sensitiveExemptions);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Match one path against the shared sensitive list. */
|
|
1346
|
+
export function matchSensitivePath(
|
|
1347
|
+
value: unknown,
|
|
1348
|
+
config: FolderPolicyConfig,
|
|
1349
|
+
cwd: string,
|
|
1350
|
+
): SensitiveMatch {
|
|
1351
|
+
const lexical = normalizePolicyPath(value, cwd);
|
|
1352
|
+
if (!lexical) return { sensitive: false };
|
|
1353
|
+
const canonical = canonicalizePolicyPath(lexical, cwd).path || lexical;
|
|
1354
|
+
for (const exemption of effectiveSensitiveExemptions(config)) {
|
|
1355
|
+
if (patternMatches(exemption, lexical, canonical, cwd))
|
|
1356
|
+
return { sensitive: false };
|
|
1357
|
+
}
|
|
1358
|
+
for (const pattern of effectiveSensitivePatterns(config)) {
|
|
1359
|
+
if (patternMatches(pattern, lexical, canonical, cwd)) {
|
|
1360
|
+
return { sensitive: true, pattern, path: lexical };
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return { sensitive: false };
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// ── Exclusion matching ───────────────────────────────────────────
|
|
1367
|
+
|
|
1368
|
+
/** The result of testing one target against `excludePaths`. */
|
|
1369
|
+
export interface ExclusionMatch {
|
|
1370
|
+
excluded: boolean;
|
|
1371
|
+
/** The glob that matched, as stored. */
|
|
1372
|
+
pattern?: string;
|
|
1373
|
+
/** The matched folder (the exclusion's root for this target). */
|
|
1374
|
+
root?: string;
|
|
1375
|
+
/** The target *is* the matched root (or resolves to it). */
|
|
1376
|
+
exact?: boolean;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const NOT_EXCLUDED: ExclusionMatch = { excluded: false };
|
|
1380
|
+
|
|
1381
|
+
/** The filesystem/drive/UNC root a path prefix walk must stop at. */
|
|
1382
|
+
function pathRootOf(value: string): string {
|
|
1383
|
+
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3);
|
|
1384
|
+
if (value.startsWith("//")) {
|
|
1385
|
+
const parts = value.split("/").filter(Boolean);
|
|
1386
|
+
if (parts.length >= 2) return `//${parts[0]}/${parts[1]}`;
|
|
1387
|
+
return parts.length === 1 ? `//${parts[0]}` : "//";
|
|
1388
|
+
}
|
|
1389
|
+
return "/";
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** Every prefix of a normalised path, deepest first, including its root. */
|
|
1393
|
+
export function pathPrefixes(value: string): string[] {
|
|
1394
|
+
if (!value) return [];
|
|
1395
|
+
const root = pathRootOf(value);
|
|
1396
|
+
const out: string[] = [];
|
|
1397
|
+
let current = value;
|
|
1398
|
+
while (true) {
|
|
1399
|
+
out.push(current);
|
|
1400
|
+
if (current === root) break;
|
|
1401
|
+
const idx = current.lastIndexOf("/");
|
|
1402
|
+
if (idx <= 0) {
|
|
1403
|
+
if (!out.includes(root)) out.push(root);
|
|
1404
|
+
break;
|
|
1405
|
+
}
|
|
1406
|
+
// `c:/users` must step to `c:/`, never to the drive-relative `c:`.
|
|
1407
|
+
let next = current.slice(0, idx);
|
|
1408
|
+
if (/^[A-Za-z]:$/.test(next)) next = `${next}/`;
|
|
1409
|
+
if (!next || next === current) break;
|
|
1410
|
+
current = next;
|
|
1411
|
+
}
|
|
1412
|
+
return out;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Test a target against the `excludePaths` globs. A matching prefix becomes
|
|
1417
|
+
* the exclusion root for that target: the target is `exact` when it *is* that
|
|
1418
|
+
* root, otherwise it sits below it. Both the lexical path and the canonical
|
|
1419
|
+
* (symlink-resolved) path are walked, and the deepest match wins so a nested
|
|
1420
|
+
* exclusion is attributed to the more specific rule.
|
|
1421
|
+
*
|
|
1422
|
+
* This is a *scope* test, not a secret test: callers decide whether an
|
|
1423
|
+
* explicit grant inside the root re-opens the target (see
|
|
1424
|
+
* `evaluateFolderTargets`).
|
|
1425
|
+
*/
|
|
1426
|
+
export function createExclusionMatcher(
|
|
1427
|
+
config: FolderPolicyConfig,
|
|
1428
|
+
): { match: (value: unknown, cwd: string) => ExclusionMatch } {
|
|
1429
|
+
const rules: { pattern: string; regex: RegExp }[] = [];
|
|
1430
|
+
const seen = new Set<string>();
|
|
1431
|
+
for (const raw of config.excludePaths) {
|
|
1432
|
+
const pattern = normalizeExcludePattern(raw);
|
|
1433
|
+
if (!pattern) continue;
|
|
1434
|
+
const key = pattern.toLowerCase();
|
|
1435
|
+
if (seen.has(key)) continue;
|
|
1436
|
+
seen.add(key);
|
|
1437
|
+
rules.push({ pattern, regex: globToRegExp(pattern) });
|
|
1438
|
+
}
|
|
1439
|
+
const deepestFirst = (target: string): ExclusionMatch | undefined => {
|
|
1440
|
+
for (const prefix of pathPrefixes(target)) {
|
|
1441
|
+
for (const rule of rules) {
|
|
1442
|
+
if (!rule.regex.test(prefix)) continue;
|
|
1443
|
+
return {
|
|
1444
|
+
excluded: true,
|
|
1445
|
+
pattern: rule.pattern,
|
|
1446
|
+
root: prefix,
|
|
1447
|
+
exact: prefix === target,
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return undefined;
|
|
1452
|
+
};
|
|
1453
|
+
return {
|
|
1454
|
+
match(value: unknown, cwd: string): ExclusionMatch {
|
|
1455
|
+
if (rules.length === 0) return NOT_EXCLUDED;
|
|
1456
|
+
const lexical = normalizePolicyPath(value, cwd);
|
|
1457
|
+
if (!lexical) return NOT_EXCLUDED;
|
|
1458
|
+
const canonical = canonicalizePolicyPath(lexical, cwd).path || lexical;
|
|
1459
|
+
const lexicalHit = deepestFirst(lexical);
|
|
1460
|
+
const canonicalHit =
|
|
1461
|
+
canonical === lexical ? undefined : deepestFirst(canonical);
|
|
1462
|
+
if (lexicalHit && canonicalHit) {
|
|
1463
|
+
// Both forms are excluded: keep the deeper attribution (more specific).
|
|
1464
|
+
return pathPrefixes(canonical).length > pathPrefixes(lexical).length
|
|
1465
|
+
? canonicalHit
|
|
1466
|
+
: lexicalHit;
|
|
1467
|
+
}
|
|
1468
|
+
return lexicalHit ?? canonicalHit ?? NOT_EXCLUDED;
|
|
1469
|
+
},
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** One-shot exclusion test (convenience for tests and single-path callers). */
|
|
1474
|
+
export function matchExcludedPath(
|
|
1475
|
+
value: unknown,
|
|
1476
|
+
config: FolderPolicyConfig,
|
|
1477
|
+
cwd: string,
|
|
1478
|
+
): ExclusionMatch {
|
|
1479
|
+
return createExclusionMatcher(config).match(value, cwd);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// ── Folder evaluation / decision ─────────────────────────────────────
|
|
1483
|
+
|
|
1484
|
+
export type FolderTargetKind = "file" | "dir" | "repo" | "glob";
|
|
1485
|
+
|
|
1486
|
+
export interface FolderTarget {
|
|
1487
|
+
path: string;
|
|
1488
|
+
kind: FolderTargetKind;
|
|
1489
|
+
reason?: string;
|
|
1490
|
+
/**
|
|
1491
|
+
* Set when `reason` describes an exclusion: the matched excluded root. The
|
|
1492
|
+
* dialogs use it to make sure the grant they offer actually sits inside the
|
|
1493
|
+
* exclusion (otherwise "Always allow" would store a grant that cannot help).
|
|
1494
|
+
*/
|
|
1495
|
+
excludeRoot?: string;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
export interface SensitiveTarget {
|
|
1499
|
+
path: string;
|
|
1500
|
+
pattern: string;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** One target refused because it is (or became) an excluded root. */
|
|
1504
|
+
export interface ExcludedTarget {
|
|
1505
|
+
path: string;
|
|
1506
|
+
pattern: string;
|
|
1507
|
+
/** The matched folder the rejection is attributed to. */
|
|
1508
|
+
root: string;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
export interface FolderEvaluation {
|
|
1512
|
+
sensitive: SensitiveTarget[];
|
|
1513
|
+
/** Targets at (or canonically at) an excluded root: hard block, no dialog. */
|
|
1514
|
+
excluded: ExcludedTarget[];
|
|
1515
|
+
outside: FolderTarget[];
|
|
1516
|
+
allowed: FolderTarget[];
|
|
1517
|
+
network: FolderTarget[];
|
|
1518
|
+
broad: FolderTarget[];
|
|
1519
|
+
uncertain: string[];
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
export interface FolderEvaluationOptions {
|
|
1523
|
+
config: FolderPolicyConfig;
|
|
1524
|
+
grants: readonly string[];
|
|
1525
|
+
cwd: string;
|
|
1526
|
+
/** Parent-approved roots for a locked subagent. */
|
|
1527
|
+
lockedRoots?: readonly string[];
|
|
1528
|
+
/**
|
|
1529
|
+
* Explicit grants that can re-open an excluded subtree in a locked session
|
|
1530
|
+
* (the reviewer inherits the parent's read grants). They never widen the
|
|
1531
|
+
* locked scope (`lexical`/`canonical`), only the rescue rule.
|
|
1532
|
+
*/
|
|
1533
|
+
explicitGrants?: readonly string[];
|
|
1534
|
+
/** Only used for diagnostics; containment is the same for all actions. */
|
|
1535
|
+
action?: "read" | "write" | "commit";
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function targetNormalized(target: FolderTarget, cwd: string): string {
|
|
1539
|
+
return normalizePolicyPath(target.path, cwd);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function isAllowedByRoots(
|
|
1543
|
+
lexical: string,
|
|
1544
|
+
canonical: string,
|
|
1545
|
+
roots: ResolvedFolderRoots,
|
|
1546
|
+
): boolean {
|
|
1547
|
+
if (roots.unrestricted) return true;
|
|
1548
|
+
// Both the requested path and the resolved path must be inside a root.
|
|
1549
|
+
if (!isInsideAnyRoot(lexical, roots.lexical)) return false;
|
|
1550
|
+
if (canonical && !isInsideAnyRoot(canonical, roots.canonical)) return false;
|
|
1551
|
+
return true;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Does an approved root re-open this target despite the exclusion?
|
|
1556
|
+
*
|
|
1557
|
+
* `allowEqual` is the whole difference between an explicit grant and an
|
|
1558
|
+
* implicit root: a user-approved grant may sit exactly on the excluded folder
|
|
1559
|
+
* (`/read-policy allow ~/Projects`), while an implicit root — the base folder,
|
|
1560
|
+
* the session cwd, a locked subagent root — must be *strictly* inside it, so
|
|
1561
|
+
* opening the home directory as the session folder never blanket-allows home.
|
|
1562
|
+
* A root at or above the exclusion never rescues.
|
|
1563
|
+
*/
|
|
1564
|
+
function rescuesExclusion(
|
|
1565
|
+
set: FolderRootSet,
|
|
1566
|
+
allowEqual: boolean,
|
|
1567
|
+
target: string,
|
|
1568
|
+
exclusionRoot: string,
|
|
1569
|
+
): boolean {
|
|
1570
|
+
return set.lexical.some((root) => {
|
|
1571
|
+
if (root === exclusionRoot && !allowEqual) return false;
|
|
1572
|
+
if (!isInsidePolicyPath(root, exclusionRoot)) return false;
|
|
1573
|
+
return isInsidePolicyPath(target, root);
|
|
1574
|
+
}) &&
|
|
1575
|
+
(set.canonical.length === 0 ||
|
|
1576
|
+
set.canonical.some((root) => {
|
|
1577
|
+
if (root === exclusionRoot && !allowEqual) return false;
|
|
1578
|
+
if (!isInsidePolicyPath(root, exclusionRoot)) return false;
|
|
1579
|
+
return isInsidePolicyPath(target, root);
|
|
1580
|
+
}));
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* True when an explicit grant or an implicit root covers the target inside
|
|
1585
|
+
* the excluded folder. Both comparison forms must agree, so a symlink that
|
|
1586
|
+
* points out of the granted folder does not sneak past the exclusion.
|
|
1587
|
+
*/
|
|
1588
|
+
function isRescuedFromExclusion(
|
|
1589
|
+
lexical: string,
|
|
1590
|
+
canonical: string,
|
|
1591
|
+
exclusionRoot: string,
|
|
1592
|
+
roots: ResolvedFolderRoots,
|
|
1593
|
+
): boolean {
|
|
1594
|
+
const rootLexical = normalizePolicyPath(exclusionRoot, exclusionRoot);
|
|
1595
|
+
if (!rootLexical) return false;
|
|
1596
|
+
const rootCanonical =
|
|
1597
|
+
canonicalizePolicyPath(rootLexical, rootLexical).path || rootLexical;
|
|
1598
|
+
const lexicalOk =
|
|
1599
|
+
rescuesExclusion(roots.explicit, true, lexical, rootLexical) ||
|
|
1600
|
+
rescuesExclusion(roots.base, false, lexical, rootLexical) ||
|
|
1601
|
+
rescuesExclusion(roots.anchor, false, lexical, rootLexical) ||
|
|
1602
|
+
rescuesExclusion(roots.locked, false, lexical, rootLexical);
|
|
1603
|
+
if (!lexicalOk) return false;
|
|
1604
|
+
return (
|
|
1605
|
+
rescuesExclusion(roots.explicit, true, canonical, rootCanonical) ||
|
|
1606
|
+
rescuesExclusion(roots.base, false, canonical, rootCanonical) ||
|
|
1607
|
+
rescuesExclusion(roots.anchor, false, canonical, rootCanonical) ||
|
|
1608
|
+
rescuesExclusion(roots.locked, false, canonical, rootCanonical)
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/** Roots a caller can supply to the exclusion rescue rule. */
|
|
1613
|
+
export interface ExclusionRescueRoots {
|
|
1614
|
+
/** Implicit roots: rescue only when *strictly* inside the exclusion. */
|
|
1615
|
+
base?: readonly string[];
|
|
1616
|
+
/** Explicit grants: rescue when at or inside the exclusion. */
|
|
1617
|
+
grants?: readonly string[];
|
|
1618
|
+
/** Parent-approved roots for a locked subagent (implicit). */
|
|
1619
|
+
locked?: readonly string[];
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Exclusion test with the rescue rule, for callers that are not evaluating a
|
|
1624
|
+
* full folder floor (output redaction, the reviewer guard). Returns the match
|
|
1625
|
+
* only when the target stays excluded; `{excluded:false}` means either no
|
|
1626
|
+
* exclusion matched or an approved root re-opened it.
|
|
1627
|
+
*/
|
|
1628
|
+
export function matchUnrescuedExclusion(
|
|
1629
|
+
value: unknown,
|
|
1630
|
+
config: FolderPolicyConfig,
|
|
1631
|
+
cwd: string,
|
|
1632
|
+
rescue: ExclusionRescueRoots = {},
|
|
1633
|
+
): ExclusionMatch {
|
|
1634
|
+
const match = createExclusionMatcher(config).match(value, cwd);
|
|
1635
|
+
if (!match.excluded || !match.root) return NOT_EXCLUDED;
|
|
1636
|
+
const lexical = normalizePolicyPath(value, cwd);
|
|
1637
|
+
if (!lexical) return NOT_EXCLUDED;
|
|
1638
|
+
const canonical = canonicalizePolicyPath(lexical, cwd).path || lexical;
|
|
1639
|
+
const roots: ResolvedFolderRoots = {
|
|
1640
|
+
lexical: [],
|
|
1641
|
+
canonical: [],
|
|
1642
|
+
unrestricted: false,
|
|
1643
|
+
base: buildRootSet(rescue.base ?? [], cwd),
|
|
1644
|
+
anchor: buildRootSet([cwd], cwd),
|
|
1645
|
+
explicit: buildRootSet(rescue.grants ?? [], cwd),
|
|
1646
|
+
locked: buildRootSet(rescue.locked ?? [], cwd),
|
|
1647
|
+
};
|
|
1648
|
+
return isRescuedFromExclusion(lexical, canonical, match.root, roots)
|
|
1649
|
+
? NOT_EXCLUDED
|
|
1650
|
+
: match;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Classify targets against the folder floor. Sensitive wins; an excluded
|
|
1655
|
+
* folder itself is refused outright, its descendants fall back to `outside`
|
|
1656
|
+
* unless an approved root rescues them; network paths need an explicit grant
|
|
1657
|
+
* even in `any` mode; canonicalisation failures are reported so the caller can
|
|
1658
|
+
* ask instead of silently trusting them.
|
|
1659
|
+
*/
|
|
1660
|
+
export function evaluateFolderTargets(
|
|
1661
|
+
targets: readonly FolderTarget[],
|
|
1662
|
+
options: FolderEvaluationOptions,
|
|
1663
|
+
): FolderEvaluation {
|
|
1664
|
+
const { config, grants, cwd, lockedRoots } = options;
|
|
1665
|
+
const resolvedRoots = resolveFolderRoots(config, grants, cwd, lockedRoots);
|
|
1666
|
+
// A locked session replaces `grants` with its approved roots, but a caller
|
|
1667
|
+
// (the reviewer) may still carry explicit grants for the rescue rule only.
|
|
1668
|
+
const roots =
|
|
1669
|
+
options.explicitGrants && options.explicitGrants.length > 0
|
|
1670
|
+
? {
|
|
1671
|
+
...resolvedRoots,
|
|
1672
|
+
explicit: mergeRootSets(
|
|
1673
|
+
resolvedRoots.explicit,
|
|
1674
|
+
buildRootSet(options.explicitGrants, cwd),
|
|
1675
|
+
),
|
|
1676
|
+
}
|
|
1677
|
+
: resolvedRoots;
|
|
1678
|
+
const matcher = createExclusionMatcher(config);
|
|
1679
|
+
const evaluation: FolderEvaluation = {
|
|
1680
|
+
sensitive: [],
|
|
1681
|
+
excluded: [],
|
|
1682
|
+
outside: [],
|
|
1683
|
+
allowed: [],
|
|
1684
|
+
network: [],
|
|
1685
|
+
broad: [],
|
|
1686
|
+
uncertain: [],
|
|
1687
|
+
};
|
|
1688
|
+
for (const target of targets) {
|
|
1689
|
+
const lexical = targetNormalized(target, cwd);
|
|
1690
|
+
if (!lexical) {
|
|
1691
|
+
evaluation.uncertain.push("empty path");
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
if (translateForeignPath(target.path).uncertain)
|
|
1695
|
+
evaluation.uncertain.push(lexical);
|
|
1696
|
+
const canonicalResult = canonicalizePolicyPath(lexical, cwd);
|
|
1697
|
+
const canonical = canonicalResult.path || lexical;
|
|
1698
|
+
const sensitive = matchSensitivePath(lexical, config, cwd);
|
|
1699
|
+
if (sensitive.sensitive) {
|
|
1700
|
+
evaluation.sensitive.push({
|
|
1701
|
+
path: lexical,
|
|
1702
|
+
pattern: sensitive.pattern ?? "sensitive",
|
|
1703
|
+
});
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
// Exclusion is a scope rule applied after the (absolute) sensitive list.
|
|
1707
|
+
const exclusion = matcher.match(lexical, cwd);
|
|
1708
|
+
if (exclusion.excluded && exclusion.root) {
|
|
1709
|
+
if (!isRescuedFromExclusion(lexical, canonical, exclusion.root, roots)) {
|
|
1710
|
+
if (exclusion.exact) {
|
|
1711
|
+
evaluation.excluded.push({
|
|
1712
|
+
path: lexical,
|
|
1713
|
+
pattern: exclusion.pattern ?? exclusion.root,
|
|
1714
|
+
root: normalizePolicyPath(exclusion.root, cwd),
|
|
1715
|
+
});
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
evaluation.outside.push({
|
|
1719
|
+
...target,
|
|
1720
|
+
reason: `inside excluded \`${normalizePolicyPath(exclusion.root, cwd)}\` (${exclusion.pattern ?? "excludePaths"})`,
|
|
1721
|
+
excludeRoot: normalizePolicyPath(exclusion.root, cwd),
|
|
1722
|
+
});
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (isBroadGrantRoot(lexical, cwd)) evaluation.broad.push(target);
|
|
1727
|
+
const network = isNetworkPath(lexical) || isNetworkPath(canonical);
|
|
1728
|
+
if (network) evaluation.network.push(target);
|
|
1729
|
+
// Network paths need an explicit per-capability grant even in `any` mode.
|
|
1730
|
+
const networkGranted =
|
|
1731
|
+
isInsideAnyRoot(lexical, roots.lexical) ||
|
|
1732
|
+
(Boolean(canonical) && isInsideAnyRoot(canonical, roots.canonical));
|
|
1733
|
+
const allowed = network
|
|
1734
|
+
? networkGranted
|
|
1735
|
+
: isAllowedByRoots(lexical, canonical, roots);
|
|
1736
|
+
if (!canonicalResult.resolved) {
|
|
1737
|
+
evaluation.uncertain.push(lexical);
|
|
1738
|
+
if (!allowed) evaluation.outside.push(target);
|
|
1739
|
+
else evaluation.allowed.push(target);
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
if (allowed) evaluation.allowed.push(target);
|
|
1743
|
+
else evaluation.outside.push(target);
|
|
1744
|
+
}
|
|
1745
|
+
return evaluation;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
export type FolderGateDecision = "allow" | "ask" | "block";
|
|
1749
|
+
|
|
1750
|
+
export interface FolderGateInput {
|
|
1751
|
+
sensitive: boolean;
|
|
1752
|
+
outside: boolean;
|
|
1753
|
+
outsideAction: FolderOutsideAction;
|
|
1754
|
+
/** A target is the excluded root itself: hard block, never a dialog. */
|
|
1755
|
+
excluded?: boolean;
|
|
1756
|
+
/** An allow-once/always grant already covers every outside target. */
|
|
1757
|
+
granted?: boolean;
|
|
1758
|
+
network?: boolean;
|
|
1759
|
+
uncertain?: boolean;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** Pure folder decision. Sensitive always wins; policy can never loosen it. */
|
|
1763
|
+
export function decideFolderGate(input: FolderGateInput): FolderGateDecision {
|
|
1764
|
+
if (input.sensitive) return "block";
|
|
1765
|
+
if (input.excluded) return "block";
|
|
1766
|
+
if (input.outside) {
|
|
1767
|
+
if (input.outsideAction === "block") return "block";
|
|
1768
|
+
if (!input.granted) return "ask";
|
|
1769
|
+
}
|
|
1770
|
+
if (input.network && !input.granted) return "ask";
|
|
1771
|
+
if (input.uncertain) {
|
|
1772
|
+
// An unverifiable target follows the user's chosen outside action: a
|
|
1773
|
+
// policy of "block" must not open a dialog, and "ask" must ask.
|
|
1774
|
+
return input.outsideAction === "block" ? "block" : "ask";
|
|
1775
|
+
}
|
|
1776
|
+
return "allow";
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/** Most restrictive wins: block > ask > allow. */
|
|
1780
|
+
export function combineGateDecisions(
|
|
1781
|
+
...decisions: readonly (FolderGateDecision | undefined)[]
|
|
1782
|
+
): FolderGateDecision {
|
|
1783
|
+
let result: FolderGateDecision = "allow";
|
|
1784
|
+
for (const decision of decisions) {
|
|
1785
|
+
if (decision === "block") return "block";
|
|
1786
|
+
if (decision === "ask") result = "ask";
|
|
1787
|
+
}
|
|
1788
|
+
return result;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/** One-line human-readable summary of the folder mode. */
|
|
1792
|
+
export function describeFolderPolicyMode(mode: FolderPolicyMode): string {
|
|
1793
|
+
switch (mode) {
|
|
1794
|
+
case "repo":
|
|
1795
|
+
return "the current repository root (or the working directory when not in git)";
|
|
1796
|
+
case "cwd":
|
|
1797
|
+
return "the session working directory only";
|
|
1798
|
+
case "parent":
|
|
1799
|
+
return "the parent of the repository/working-directory base (siblings)";
|
|
1800
|
+
case "any":
|
|
1801
|
+
return "any folder (sensitive files still blocked)";
|
|
1802
|
+
default:
|
|
1803
|
+
return "the current repository root";
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Human-readable assessment of one folder evaluation, for `/permissions
|
|
1809
|
+
* explain` and the folder status output. Bounded so a broad command cannot
|
|
1810
|
+
* flood the transcript; `` allowed/outside/sensitive/uncertain counts are
|
|
1811
|
+
* always shown.
|
|
1812
|
+
*/
|
|
1813
|
+
export function describeFolderEvaluation(
|
|
1814
|
+
evaluation: FolderEvaluation,
|
|
1815
|
+
base: string,
|
|
1816
|
+
): string[] {
|
|
1817
|
+
const MAX = 8;
|
|
1818
|
+
const lines = [
|
|
1819
|
+
`Folder base: ${base}`,
|
|
1820
|
+
` targets: ${evaluation.allowed.length} inside, ${evaluation.outside.length} outside, ${evaluation.excluded.length} excluded, ${evaluation.sensitive.length} sensitive, ${evaluation.uncertain.length} unverifiable`,
|
|
1821
|
+
];
|
|
1822
|
+
const add = (label: string, values: readonly string[]) => {
|
|
1823
|
+
for (const value of values.slice(0, MAX)) lines.push(` ${label}: ${value}`);
|
|
1824
|
+
if (values.length > MAX) {
|
|
1825
|
+
lines.push(` ${label}: … and ${values.length - MAX} more`);
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
add(
|
|
1829
|
+
"inside",
|
|
1830
|
+
evaluation.allowed.map((target) => target.path),
|
|
1831
|
+
);
|
|
1832
|
+
add(
|
|
1833
|
+
"outside",
|
|
1834
|
+
evaluation.outside.map((target) => target.path),
|
|
1835
|
+
);
|
|
1836
|
+
add(
|
|
1837
|
+
"excluded",
|
|
1838
|
+
evaluation.excluded.map((hit) => `${hit.path} (${hit.pattern})`),
|
|
1839
|
+
);
|
|
1840
|
+
add(
|
|
1841
|
+
"network",
|
|
1842
|
+
evaluation.network.map((target) => target.path),
|
|
1843
|
+
);
|
|
1844
|
+
add(
|
|
1845
|
+
"sensitive",
|
|
1846
|
+
evaluation.sensitive.map((hit) => `${hit.path} (${hit.pattern})`),
|
|
1847
|
+
);
|
|
1848
|
+
add("unverifiable", evaluation.uncertain);
|
|
1849
|
+
return lines;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// ── Base coverage (per capability) ─────────────────────────────────
|
|
1853
|
+
|
|
1854
|
+
/** The three capabilities that share the folder floor. */
|
|
1855
|
+
export type BaseCapability = "read" | "write" | "commit";
|
|
1856
|
+
|
|
1857
|
+
const BASE_CAPABILITIES: readonly BaseCapability[] = [
|
|
1858
|
+
"read",
|
|
1859
|
+
"write",
|
|
1860
|
+
"commit",
|
|
1861
|
+
];
|
|
1862
|
+
|
|
1863
|
+
/** One capability's verdict for the session base folder. */
|
|
1864
|
+
export interface BaseCoverageEntry {
|
|
1865
|
+
capability: BaseCapability;
|
|
1866
|
+
/** The base *is* an excluded root and nothing rescued it: hard block. */
|
|
1867
|
+
excluded: ExcludedTarget[];
|
|
1868
|
+
/** An exclusion (or the floor) puts the base outside for this capability. */
|
|
1869
|
+
outside: FolderTarget[];
|
|
1870
|
+
/** An explicit grant that re-opened the base inside an exclusion. */
|
|
1871
|
+
rescuedBy?: string;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Evaluate the session base folder once per capability, each with **its own**
|
|
1876
|
+
* grants.
|
|
1877
|
+
*
|
|
1878
|
+
* The plain folder evaluation cannot answer "is the session crippled?"
|
|
1879
|
+
* because read/write/commit have separate `allowedPaths`: a read grant can
|
|
1880
|
+
* rescue reads while writes and commits stay refused. Callers use this to warn
|
|
1881
|
+
* only about the capabilities that would actually be refused.
|
|
1882
|
+
*/
|
|
1883
|
+
export function evaluateBaseCoverage(input: {
|
|
1884
|
+
config: FolderPolicyConfig;
|
|
1885
|
+
cwd: string;
|
|
1886
|
+
grantsByCapability?: Partial<
|
|
1887
|
+
Record<BaseCapability, readonly string[]>
|
|
1888
|
+
>;
|
|
1889
|
+
lockedRoots?: readonly string[];
|
|
1890
|
+
}): BaseCoverageEntry[] {
|
|
1891
|
+
const { config, cwd, grantsByCapability = {}, lockedRoots } = input;
|
|
1892
|
+
const base = baseFolderRoots(config, cwd)[0] ?? cwd;
|
|
1893
|
+
const exclusion = createExclusionMatcher(config).match(base, cwd);
|
|
1894
|
+
const exclusionRoot =
|
|
1895
|
+
exclusion.excluded && exclusion.root
|
|
1896
|
+
? normalizePolicyPath(exclusion.root, cwd)
|
|
1897
|
+
: "";
|
|
1898
|
+
return BASE_CAPABILITIES.map((capability) => {
|
|
1899
|
+
const grants = [...(grantsByCapability[capability] ?? [])];
|
|
1900
|
+
const evaluation = evaluateFolderTargets(
|
|
1901
|
+
[{ path: base, kind: "dir" }],
|
|
1902
|
+
{ config, grants, cwd, lockedRoots, action: capability },
|
|
1903
|
+
);
|
|
1904
|
+
const entry: BaseCoverageEntry = {
|
|
1905
|
+
capability,
|
|
1906
|
+
excluded: evaluation.excluded,
|
|
1907
|
+
outside: evaluation.outside,
|
|
1908
|
+
};
|
|
1909
|
+
// No refusal at the base + an exclusion covers it ⇒ a grant rescued it.
|
|
1910
|
+
if (
|
|
1911
|
+
exclusionRoot &&
|
|
1912
|
+
entry.excluded.length === 0 &&
|
|
1913
|
+
entry.outside.length === 0
|
|
1914
|
+
) {
|
|
1915
|
+
for (const grant of grants) {
|
|
1916
|
+
const normalized = normalizePolicyPath(grant, cwd);
|
|
1917
|
+
if (normalized && isInsidePolicyPath(normalized, exclusionRoot)) {
|
|
1918
|
+
entry.rescuedBy = normalized;
|
|
1919
|
+
break;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
return entry;
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// ── /folder-policy argument parsing ─────────────────────────────────
|
|
1928
|
+
|
|
1929
|
+
/** One-line usage for `/folder-policy exclude`, shared by every message. */
|
|
1930
|
+
export const EXCLUDE_USAGE =
|
|
1931
|
+
"Usage: /folder-policy exclude <path>|add <path>|revoke <path>|list [--global|--project]";
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* Split a slash-command argument string into tokens.
|
|
1935
|
+
*
|
|
1936
|
+
* Quotes (`"…"` and `'…'`) group a path that contains spaces; the opening
|
|
1937
|
+
* quote's partner closes it, so the other quote kind is literal. Backslashes
|
|
1938
|
+
* are **never** escapes — a Windows path must survive verbatim (this is why
|
|
1939
|
+
* `shell-text.tokenizeWords` is not reused: it POSIX-unescapes `\P`).
|
|
1940
|
+
* An unbalanced quote is reported instead of being guessed at.
|
|
1941
|
+
*/
|
|
1942
|
+
export function splitPolicyArgs(text: unknown): {
|
|
1943
|
+
tokens: string[];
|
|
1944
|
+
error?: string;
|
|
1945
|
+
} {
|
|
1946
|
+
const source = typeof text === "string" ? text : "";
|
|
1947
|
+
const tokens: string[] = [];
|
|
1948
|
+
let current = "";
|
|
1949
|
+
let hasToken = false;
|
|
1950
|
+
let quote: "'" | '"' | undefined;
|
|
1951
|
+
for (const ch of source) {
|
|
1952
|
+
if (quote) {
|
|
1953
|
+
if (ch === quote) quote = undefined;
|
|
1954
|
+
else current += ch;
|
|
1955
|
+
hasToken = true;
|
|
1956
|
+
continue;
|
|
1957
|
+
}
|
|
1958
|
+
if (ch === '"' || ch === "'") {
|
|
1959
|
+
quote = ch;
|
|
1960
|
+
hasToken = true;
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1963
|
+
if (/\s/.test(ch)) {
|
|
1964
|
+
if (hasToken) {
|
|
1965
|
+
tokens.push(current);
|
|
1966
|
+
current = "";
|
|
1967
|
+
hasToken = false;
|
|
1968
|
+
}
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
current += ch;
|
|
1972
|
+
hasToken = true;
|
|
1973
|
+
}
|
|
1974
|
+
if (quote) {
|
|
1975
|
+
return {
|
|
1976
|
+
tokens: [],
|
|
1977
|
+
error: `Unbalanced ${quote === '"' ? "double" : "single"} quote. Close it, or quote the whole path once.`,
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
if (hasToken) tokens.push(current);
|
|
1981
|
+
return { tokens };
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
export type ExcludeAction = "add" | "revoke" | "list";
|
|
1985
|
+
|
|
1986
|
+
export type ExcludeArgsResult =
|
|
1987
|
+
| {
|
|
1988
|
+
ok: true;
|
|
1989
|
+
action: ExcludeAction;
|
|
1990
|
+
scope: "global" | "project";
|
|
1991
|
+
/** Present for add/revoke; absent for list. */
|
|
1992
|
+
pattern?: string;
|
|
1993
|
+
}
|
|
1994
|
+
| { ok: false; message: string };
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Interpret the tokens after `exclude`.
|
|
1998
|
+
*
|
|
1999
|
+
* Scope is **flags only** (`--global` / `--project`). A standalone
|
|
2000
|
+
* `global`/`project` is just an argument, so a folder literally named
|
|
2001
|
+
* `project` can be excluded and a trailing word can never silently retarget
|
|
2002
|
+
* the write to another settings file. Anything else unexpected is rejected
|
|
2003
|
+
* rather than dropped.
|
|
2004
|
+
*/
|
|
2005
|
+
export function parseExcludeArgs(
|
|
2006
|
+
tokens: readonly string[],
|
|
2007
|
+
): ExcludeArgsResult {
|
|
2008
|
+
let scope: "global" | "project" | undefined;
|
|
2009
|
+
const rest: string[] = [];
|
|
2010
|
+
for (const token of tokens) {
|
|
2011
|
+
if (token === "--global" || token === "--project") {
|
|
2012
|
+
const next = token === "--global" ? "global" : "project";
|
|
2013
|
+
if (scope && scope !== next)
|
|
2014
|
+
return {
|
|
2015
|
+
ok: false,
|
|
2016
|
+
message: `Choose one scope, not both --global and --project.\n${EXCLUDE_USAGE}`,
|
|
2017
|
+
};
|
|
2018
|
+
scope = next;
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
if (token.startsWith("--"))
|
|
2022
|
+
return {
|
|
2023
|
+
ok: false,
|
|
2024
|
+
message: `Unknown flag \`${token}\`.\n${EXCLUDE_USAGE}`,
|
|
2025
|
+
};
|
|
2026
|
+
rest.push(token);
|
|
2027
|
+
}
|
|
2028
|
+
const resolvedScope = scope ?? "global";
|
|
2029
|
+
if (rest.length === 0)
|
|
2030
|
+
return { ok: true, action: "list", scope: resolvedScope };
|
|
2031
|
+
const first = (rest[0] ?? "").toLowerCase();
|
|
2032
|
+
if (first === "list") {
|
|
2033
|
+
if (rest.length > 1)
|
|
2034
|
+
return {
|
|
2035
|
+
ok: false,
|
|
2036
|
+
message: `\`exclude list\` takes no path.\n${EXCLUDE_USAGE}`,
|
|
2037
|
+
};
|
|
2038
|
+
return { ok: true, action: "list", scope: resolvedScope };
|
|
2039
|
+
}
|
|
2040
|
+
if (first === "add" || first === "revoke" || first === "remove") {
|
|
2041
|
+
const action: ExcludeAction = first === "add" ? "add" : "revoke";
|
|
2042
|
+
if (rest.length < 2)
|
|
2043
|
+
return {
|
|
2044
|
+
ok: false,
|
|
2045
|
+
message: `\`exclude ${first}\` needs a path.\n${EXCLUDE_USAGE}`,
|
|
2046
|
+
};
|
|
2047
|
+
if (rest.length > 2)
|
|
2048
|
+
return {
|
|
2049
|
+
ok: false,
|
|
2050
|
+
message: `Unexpected argument \`${rest[2]}\`. Quote paths that contain spaces, and use --global/--project for the scope.\n${EXCLUDE_USAGE}`,
|
|
2051
|
+
};
|
|
2052
|
+
return {
|
|
2053
|
+
ok: true,
|
|
2054
|
+
action,
|
|
2055
|
+
scope: resolvedScope,
|
|
2056
|
+
pattern: rest[1] ?? "",
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
// Bare form: `exclude <path>` adds.
|
|
2060
|
+
if (rest.length > 1)
|
|
2061
|
+
return {
|
|
2062
|
+
ok: false,
|
|
2063
|
+
message: `Unexpected argument \`${rest[1]}\`. Quote paths that contain spaces, and use --global/--project for the scope.\n${EXCLUDE_USAGE}`,
|
|
2064
|
+
};
|
|
2065
|
+
return {
|
|
2066
|
+
ok: true,
|
|
2067
|
+
action: "add",
|
|
2068
|
+
scope: resolvedScope,
|
|
2069
|
+
pattern: rest[0] ?? "",
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// ── /folder-policy completions ───────────────────────────────────────
|
|
2074
|
+
|
|
2075
|
+
const FOLDER_POLICY_SUBS: readonly {
|
|
2076
|
+
name: string;
|
|
2077
|
+
description: string;
|
|
2078
|
+
}[] = [
|
|
2079
|
+
{ name: "status", description: "show mode, outside, grants and sensitive rules" },
|
|
2080
|
+
{ name: "repo", description: "only the nearest git repository root (default)" },
|
|
2081
|
+
{ name: "cwd", description: "only the session working directory" },
|
|
2082
|
+
{ name: "parent", description: "the parent folder (siblings) as well" },
|
|
2083
|
+
{ name: "any", description: "no folder containment; sensitive files still blocked" },
|
|
2084
|
+
{ name: "outside", description: "ask or block reads/writes/commits outside the roots" },
|
|
2085
|
+
{ name: "grant", description: "project or global for 'Always allow' grants" },
|
|
2086
|
+
{ name: "templates", description: "allow or block .env.example-style templates" },
|
|
2087
|
+
{ name: "sensitive", description: "list the sensitive deny list and exemptions" },
|
|
2088
|
+
{
|
|
2089
|
+
name: "exclude",
|
|
2090
|
+
description:
|
|
2091
|
+
"deny a path outright: <path>|add|revoke|list [--global|--project]; quote paths with spaces",
|
|
2092
|
+
},
|
|
2093
|
+
];
|
|
2094
|
+
|
|
2095
|
+
const FOLDER_SCOPES: readonly string[] = ["global", "project"];
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* Full-argument completions for `/folder-policy`, following pi's contract:
|
|
2099
|
+
* accepting a suggestion replaces the entire argument text.
|
|
2100
|
+
*/
|
|
2101
|
+
export function folderPolicyArgumentCompletions(
|
|
2102
|
+
prefix: string,
|
|
2103
|
+
): ArgumentCompletion[] | null {
|
|
2104
|
+
const text = String(prefix ?? "");
|
|
2105
|
+
const trailing = /\s$/.test(text);
|
|
2106
|
+
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
2107
|
+
const committed = trailing ? tokens : tokens.slice(0, -1);
|
|
2108
|
+
const partial = trailing ? "" : (tokens[tokens.length - 1] ?? "");
|
|
2109
|
+
const first = committed[0] ?? "";
|
|
2110
|
+
|
|
2111
|
+
const candidates: { value: string; label: string; description?: string }[] =
|
|
2112
|
+
[];
|
|
2113
|
+
const add = (value: string, description?: string) =>
|
|
2114
|
+
candidates.push({ value, label: value, description });
|
|
2115
|
+
|
|
2116
|
+
if (committed.length === 0) {
|
|
2117
|
+
for (const sub of FOLDER_POLICY_SUBS) add(sub.name, sub.description);
|
|
2118
|
+
} else if (committed.length === 1) {
|
|
2119
|
+
if (first === "outside") {
|
|
2120
|
+
add("ask", "prompt once per turn; headless fails closed");
|
|
2121
|
+
add("block", "refuse outside without asking");
|
|
2122
|
+
} else if (first === "grant") {
|
|
2123
|
+
add("project", "persist new grants in <cwd>/.pi/settings.json");
|
|
2124
|
+
add("global", "persist new grants in ~/.pi/agent/settings.json");
|
|
2125
|
+
} else if (first === "templates") {
|
|
2126
|
+
add("allow", "allow .env.example/.sample/.template/.dist");
|
|
2127
|
+
add("block", "treat templates as sensitive");
|
|
2128
|
+
} else if (first === "exclude") {
|
|
2129
|
+
add("add", "append an exclude path (the bare form also adds)");
|
|
2130
|
+
add("revoke", "remove exactly one exclude path");
|
|
2131
|
+
add("list", "show the effective exclude list");
|
|
2132
|
+
add("--global", "write ~/.pi/agent/settings.json (default)");
|
|
2133
|
+
add("--project", "write <cwd>/.pi/settings.json");
|
|
2134
|
+
} else if (["repo", "cwd", "parent", "any"].includes(first)) {
|
|
2135
|
+
for (const scope of FOLDER_SCOPES) add(scope);
|
|
2136
|
+
}
|
|
2137
|
+
} else if (committed.length === 2) {
|
|
2138
|
+
if (["outside", "grant", "templates", "sensitive"].includes(first)) {
|
|
2139
|
+
for (const scope of FOLDER_SCOPES) add(scope);
|
|
2140
|
+
} else if (
|
|
2141
|
+
first === "exclude" &&
|
|
2142
|
+
["add", "revoke"].includes(committed[1] ?? "")
|
|
2143
|
+
) {
|
|
2144
|
+
add("--global", "write the global settings file (default)");
|
|
2145
|
+
add("--project", "write the project settings file");
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
const filtered = candidates.filter((candidate) =>
|
|
2150
|
+
candidate.value.toLowerCase().startsWith(partial.toLowerCase()),
|
|
2151
|
+
);
|
|
2152
|
+
if (filtered.length === 0) return null;
|
|
2153
|
+
if (
|
|
2154
|
+
suppressExactArgumentMatch(
|
|
2155
|
+
filtered.map((candidate) => candidate.value),
|
|
2156
|
+
partial,
|
|
2157
|
+
)
|
|
2158
|
+
)
|
|
2159
|
+
return null;
|
|
2160
|
+
return filtered.map((candidate) => ({
|
|
2161
|
+
value: joinArgumentValue(text, candidate.value),
|
|
2162
|
+
label: candidate.label,
|
|
2163
|
+
...(candidate.description ? { description: candidate.description } : {}),
|
|
2164
|
+
}));
|
|
2165
|
+
}
|