@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/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
- * Settings live under `workflow.folderPolicy` in `~/.pi/agent/settings.json`
28
- * (global) or `<cwd>/.pi/settings.json` (project). Project settings are only
29
- * honoured when the caller says the project is trusted. Writes preserve every
30
- * other key in the settings file.
31
- *
32
- * This module holds the pure/I-O-light parts (config, path canonicalisation,
33
- * repo-root detection, sensitive matching, the folder decision) so they can
34
- * be unit-tested without a Pi runtime. Nothing here imports the Pi SDK.
35
- */
36
-
37
- import * as fs from "node:fs";
38
- import * as os from "node:os";
39
- import * as path from "node:path";
40
-
41
- import type { ArgumentCompletion } from "./utils.ts";
42
- import {
43
- joinArgumentValue,
44
- suppressExactArgumentMatch,
45
- translateForeignPath,
46
- } from "./utils.ts";
47
- import {
48
- COMMIT_CONFIG_DIR,
49
- WORKFLOW_SETTINGS_KEY,
50
- type JsonObject,
51
- } from "./commit-policy.ts";
52
-
53
- /** Nested key under WORKFLOW_SETTINGS_KEY. */
54
- export const FOLDER_POLICY_KEY = "folderPolicy";
55
-
56
- /** Which base folder the floor is anchored to. */
57
- export type FolderPolicyMode = "repo" | "cwd" | "parent" | "any";
58
-
59
- /** What happens when a target is outside the allowed roots. */
60
- export type FolderOutsideAction = "ask" | "block";
61
-
62
- /** Where "Always allow" dialogs persist new grants. */
63
- export type FolderGrantScope = "project" | "global";
64
-
65
- /** Whether the default `.env.example`-style exemptions are active. */
66
- export type FolderSensitiveTemplates = "allow" | "block";
67
-
68
- export interface FolderPolicyConfig {
69
- mode: FolderPolicyMode;
70
- outside: FolderOutsideAction;
71
- grantScope: FolderGrantScope;
72
- sensitiveTemplates: FolderSensitiveTemplates;
73
- /** Extra glob patterns denied in every policy. */
74
- sensitivePatterns: string[];
75
- /** Manual glob exemptions. No dialog writes this list. */
76
- sensitiveExemptions: string[];
77
- }
78
-
79
- export const DEFAULT_FOLDER_POLICY_CONFIG: Readonly<FolderPolicyConfig> = {
80
- mode: "repo",
81
- outside: "ask",
82
- grantScope: "project",
83
- sensitiveTemplates: "allow",
84
- sensitivePatterns: [],
85
- sensitiveExemptions: [],
86
- };
87
-
88
- const FOLDER_POLICY_MODES: readonly FolderPolicyMode[] = [
89
- "repo",
90
- "cwd",
91
- "parent",
92
- "any",
93
- ];
94
- const OUTSIDE_ACTIONS: readonly FolderOutsideAction[] = ["ask", "block"];
95
- const GRANT_SCOPES: readonly FolderGrantScope[] = ["project", "global"];
96
- const TEMPLATE_MODES: readonly FolderSensitiveTemplates[] = ["allow", "block"];
97
-
98
- /**
99
- * The default sensitive deny list. Patterns use forward slashes and are
100
- * matched as path suffixes, so `**\/.env` matches `.env` at any depth.
101
- * Directory rules are listed twice for the directory itself and descendants.
102
- */
103
- export const DEFAULT_SENSITIVE_PATTERNS: readonly string[] = [
104
- "**/.env",
105
- "**/.env.*",
106
- "**/.envrc",
107
- "**/.envrc.*",
108
- "**/.npmrc",
109
- "**/.yarnrc",
110
- "**/.yarnrc.yml",
111
- "**/.pypirc",
112
- "**/.netrc",
113
- "**/_netrc",
114
- "**/.git-credentials",
115
- "**/.authinfo",
116
- "**/.git/config",
117
- "**/.git/credentials",
118
- "**/.docker/config.json",
119
- "**/auth.json",
120
- "**/credentials.json",
121
- "**/credentials.yml",
122
- "**/credentials.yaml",
123
- "**/secrets.json",
124
- "**/secrets.yml",
125
- "**/secrets.yaml",
126
- "**/secrets.toml",
127
- "**/secrets.ini",
128
- "**/service-account*.json",
129
- "**/*-credentials.json",
130
- "**/*-service-account.json",
131
- "**/id_rsa",
132
- "**/id_dsa",
133
- "**/id_ecdsa",
134
- "**/id_ed25519",
135
- "**/id_ecdsa_sk",
136
- "**/id_ed25519_sk",
137
- "**/*.pem",
138
- "**/*.key",
139
- "**/*.p12",
140
- "**/*.pfx",
141
- "**/*.jks",
142
- "**/*.keystore",
143
- "**/*.kdbx",
144
- "**/*.kdb",
145
- "**/*.ppk",
146
- "**/*.p8",
147
- "**/*.ovpn",
148
- "**/*.mobileprovision",
149
- "**/*.tfvars",
150
- "**/*.tfstate",
151
- "**/*.tfstate.backup",
152
- "**/*.secret",
153
- "**/*.secrets",
154
- "**/.pgpass",
155
- "**/.my.cnf",
156
- "**/.htpasswd",
157
- "**/*.jwk",
158
- "**/.ssh",
159
- "**/.ssh/**",
160
- "**/.gnupg",
161
- "**/.gnupg/**",
162
- "**/.aws",
163
- "**/.aws/**",
164
- "**/.azure",
165
- "**/.azure/**",
166
- "**/.kube",
167
- "**/.kube/**",
168
- "**/.password-store",
169
- "**/.password-store/**",
170
- "**/.terraform",
171
- "**/.terraform/**",
172
- "**/.config/gh",
173
- "**/.config/gh/**",
174
- "**/.config/gcloud",
175
- "**/.config/gcloud/**",
176
- "**/.config/sops",
177
- "**/.config/sops/**",
178
- "**/.config/rclone",
179
- "**/.config/rclone/**",
180
- "**/Library/Keychains",
181
- "**/Library/Keychains/**",
182
- "**/AppData/Roaming/Microsoft/Credentials",
183
- "**/AppData/Roaming/Microsoft/Credentials/**",
184
- "**/AppData/Local/Microsoft/Credentials",
185
- "**/AppData/Local/Microsoft/Credentials/**",
186
- ];
187
-
188
- /** Templates that are safe by default; removed by `sensitiveTemplates: "block"`. */
189
- export const DEFAULT_SENSITIVE_EXEMPTIONS: readonly string[] = [
190
- "**/.env.example",
191
- "**/.env.sample",
192
- "**/.env.template",
193
- "**/.env.dist",
194
- ];
195
-
196
- /** A raw `workflow.folderPolicy` fragment (object or bare mode string). */
197
- export type RawFolderPolicySetting = JsonObject | string;
198
-
199
- function coerceEnum<T extends string>(
200
- value: unknown,
201
- allowed: readonly T[],
202
- ): T | undefined {
203
- if (typeof value !== "string") return undefined;
204
- const normalized = value.trim().toLowerCase();
205
- return (allowed as readonly string[]).includes(normalized)
206
- ? (normalized as T)
207
- : undefined;
208
- }
209
-
210
- function coerceStringList(value: unknown): string[] | undefined {
211
- if (!Array.isArray(value)) return undefined;
212
- const out: string[] = [];
213
- for (const item of value) {
214
- if (typeof item !== "string") continue;
215
- const trimmed = item.trim();
216
- if (!trimmed || out.includes(trimmed)) continue;
217
- out.push(trimmed);
218
- }
219
- return out;
220
- }
221
-
222
- function unionStrings(a: readonly string[], b: readonly string[]): string[] {
223
- const out = [...a];
224
- for (const item of b) if (!out.includes(item)) out.push(item);
225
- return out;
226
- }
227
-
228
- /** Coerce an untrusted fragment into a complete FolderPolicyConfig. */
229
- export function coerceFolderPolicyConfig(
230
- raw: unknown,
231
- base: FolderPolicyConfig = DEFAULT_FOLDER_POLICY_CONFIG as FolderPolicyConfig,
232
- ): FolderPolicyConfig {
233
- if (typeof raw === "string") {
234
- const mode = coerceEnum(raw, FOLDER_POLICY_MODES);
235
- return { ...base, mode: mode ?? base.mode };
236
- }
237
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
238
- return { ...base };
239
- }
240
- const obj = raw as JsonObject;
241
- return {
242
- mode: coerceEnum(obj.mode, FOLDER_POLICY_MODES) ?? base.mode,
243
- outside: coerceEnum(obj.outside, OUTSIDE_ACTIONS) ?? base.outside,
244
- grantScope: coerceEnum(obj.grantScope, GRANT_SCOPES) ?? base.grantScope,
245
- sensitiveTemplates:
246
- coerceEnum(obj.sensitiveTemplates, TEMPLATE_MODES) ??
247
- base.sensitiveTemplates,
248
- sensitivePatterns:
249
- coerceStringList(obj.sensitivePatterns) ?? [...base.sensitivePatterns],
250
- sensitiveExemptions:
251
- coerceStringList(obj.sensitiveExemptions) ??
252
- [...base.sensitiveExemptions],
253
- };
254
- }
255
-
256
- /** Extract the `workflow.folderPolicy` fragment from a raw settings object. */
257
- export function extractFolderPolicyRaw(
258
- settings: unknown,
259
- ): RawFolderPolicySetting | undefined {
260
- if (!settings || typeof settings !== "object" || Array.isArray(settings))
261
- return undefined;
262
- const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
263
- if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
264
- const raw = (wf as JsonObject)[FOLDER_POLICY_KEY];
265
- if (typeof raw === "string") return raw;
266
- if (raw && typeof raw === "object" && !Array.isArray(raw))
267
- return raw as JsonObject;
268
- return undefined;
269
- }
270
-
271
- /** Merge global then project fragments; scalars project-over-global, lists union. */
272
- export function mergeFolderPolicyConfig(
273
- globalRaw: unknown,
274
- projectRaw: unknown,
275
- ): FolderPolicyConfig {
276
- const fromGlobal = coerceFolderPolicyConfig(globalRaw);
277
- const fromProject = coerceFolderPolicyConfig(projectRaw, fromGlobal);
278
- return {
279
- mode: fromProject.mode,
280
- outside: fromProject.outside,
281
- grantScope: fromProject.grantScope,
282
- sensitiveTemplates: fromProject.sensitiveTemplates,
283
- sensitivePatterns: unionStrings(
284
- fromGlobal.sensitivePatterns,
285
- fromProject.sensitivePatterns,
286
- ),
287
- sensitiveExemptions: unionStrings(
288
- fromGlobal.sensitiveExemptions,
289
- fromProject.sensitiveExemptions,
290
- ),
291
- };
292
- }
293
-
294
- export interface FolderPolicyConfigPaths {
295
- global: string;
296
- project: string;
297
- }
298
-
299
- export function folderPolicyConfigPaths(
300
- cwd: string,
301
- agentDir: string,
302
- ): FolderPolicyConfigPaths {
303
- const nativeCwd = translateForeignPath(cwd).path || cwd;
304
- const nativeAgentDir = translateForeignPath(agentDir).path || agentDir;
305
- return {
306
- global: path.join(nativeAgentDir, "settings.json"),
307
- project: path.join(nativeCwd, COMMIT_CONFIG_DIR, "settings.json"),
308
- };
309
- }
310
-
311
- function readJsonSafe(fp: string): JsonObject | undefined {
312
- try {
313
- if (!fs.existsSync(fp)) return undefined;
314
- const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
315
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
316
- return undefined;
317
- return parsed as JsonObject;
318
- } catch {
319
- return undefined;
320
- }
321
- }
322
-
323
- /** Which settings scope supplied a scalar value. */
324
- export type FolderPolicyScopeSource = "default" | "global" | "project";
325
-
326
- export function folderPolicyModeSourceOf(
327
- globalRaw: RawFolderPolicySetting | undefined,
328
- projectRaw: RawFolderPolicySetting | undefined,
329
- ): FolderPolicyScopeSource {
330
- const hasMode = (raw: unknown): boolean => {
331
- if (typeof raw === "string")
332
- return coerceEnum(raw, FOLDER_POLICY_MODES) !== undefined;
333
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
334
- return (
335
- coerceEnum((raw as JsonObject).mode, FOLDER_POLICY_MODES) !== undefined
336
- );
337
- };
338
- if (hasMode(projectRaw)) return "project";
339
- if (hasMode(globalRaw)) return "global";
340
- return "default";
341
- }
342
-
343
- export interface FolderPolicyReadOptions {
344
- /** Project settings are ignored unless this is explicitly true. */
345
- trusted?: boolean;
346
- }
347
-
348
- export interface ResolvedFolderPolicy {
349
- config: FolderPolicyConfig;
350
- sources: {
351
- global: boolean;
352
- project: boolean;
353
- /** True when a project fragment existed but was ignored (untrusted). */
354
- projectIgnored: boolean;
355
- mode: FolderPolicyScopeSource;
356
- };
357
- /**
358
- * Present for spawned subagents: the parent-approved roots. When set, the
359
- * caller must pass these to resolveFolderRoots and ignore per-policy grants.
360
- */
361
- locked?: { roots: string[]; source: string };
362
- }
363
-
364
- function parseLockedRoots(raw: string | undefined): string[] | undefined {
365
- if (!raw || !raw.trim()) return undefined;
366
- try {
367
- const parsed: unknown = JSON.parse(raw);
368
- if (!Array.isArray(parsed)) return undefined;
369
- const out = parsed.filter(
370
- (item): item is string => typeof item === "string" && item.trim() !== "",
371
- );
372
- return out.length > 0 ? out : undefined;
373
- } catch {
374
- return undefined;
375
- }
376
- }
377
-
378
- /**
379
- * Resolve the effective folder policy from disk. Never throws. Corrupt or
380
- * missing files fall back to defaults. Project settings are ignored unless
381
- * `trusted` is true. A spawned subagent with `PI_FOLDER_POLICY_LOCKED=1` gets
382
- * the parent's roots and cannot read a more permissive project file.
383
- */
384
- export function readFolderPolicyConfig(
385
- cwd: string,
386
- agentDir: string,
387
- options: FolderPolicyReadOptions = {},
388
- ): ResolvedFolderPolicy {
389
- const paths = folderPolicyConfigPaths(cwd, agentDir);
390
- const globalSettings = readJsonSafe(paths.global);
391
- const globalRaw = extractFolderPolicyRaw(globalSettings);
392
-
393
- const lockedRoots =
394
- process.env.PI_FOLDER_POLICY_LOCKED === "1"
395
- ? parseLockedRoots(process.env.PI_FOLDER_POLICY_ROOTS)
396
- : undefined;
397
- if (lockedRoots) {
398
- return {
399
- config: {
400
- ...DEFAULT_FOLDER_POLICY_CONFIG,
401
- mode: "cwd",
402
- outside: "block",
403
- },
404
- sources: {
405
- global: globalRaw !== undefined,
406
- project: false,
407
- projectIgnored: true,
408
- mode: "default",
409
- },
410
- locked: {
411
- roots: lockedRoots,
412
- source: process.env.PI_FOLDER_POLICY_SOURCE ?? "parent",
413
- },
414
- };
415
- }
416
-
417
- const projectSettings = readJsonSafe(paths.project);
418
- const projectRaw = extractFolderPolicyRaw(projectSettings);
419
- const trusted = options.trusted === true;
420
- const effectiveProjectRaw = trusted ? projectRaw : undefined;
421
- return {
422
- config: mergeFolderPolicyConfig(globalRaw, effectiveProjectRaw),
423
- sources: {
424
- global: globalRaw !== undefined,
425
- project: projectRaw !== undefined,
426
- projectIgnored: projectRaw !== undefined && !trusted,
427
- mode: folderPolicyModeSourceOf(globalRaw, effectiveProjectRaw),
428
- },
429
- };
430
- }
431
-
432
- /**
433
- * Read-modify-write `workflow.folderPolicy`. Preserves every other settings
434
- * key. Scalars are replaced; the two pattern lists are additive. Refuses to
435
- * write while a subagent lock is active.
436
- */
437
- export function writeFolderPolicyConfig(
438
- scope: "global" | "project",
439
- cwd: string,
440
- agentDir: string,
441
- patch: Partial<FolderPolicyConfig>,
442
- ): { path: string; ok: boolean; error?: string } {
443
- const paths = folderPolicyConfigPaths(cwd, agentDir);
444
- const fp = scope === "project" ? paths.project : paths.global;
445
- if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
446
- return {
447
- path: fp,
448
- ok: false,
449
- error: "folder policy is locked for this subagent",
450
- };
451
- }
452
- try {
453
- const existing = readJsonSafe(fp);
454
- const root: JsonObject = existing ? { ...existing } : {};
455
- const wfExisting = root[WORKFLOW_SETTINGS_KEY];
456
- const wf: JsonObject =
457
- wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
458
- ? { ...(wfExisting as JsonObject) }
459
- : {};
460
- const current = coerceFolderPolicyConfig(wf[FOLDER_POLICY_KEY]);
461
- const next: FolderPolicyConfig = {
462
- mode: coerceEnum(patch.mode, FOLDER_POLICY_MODES) ?? current.mode,
463
- outside:
464
- coerceEnum(patch.outside, OUTSIDE_ACTIONS) ?? current.outside,
465
- grantScope:
466
- coerceEnum(patch.grantScope, GRANT_SCOPES) ?? current.grantScope,
467
- sensitiveTemplates:
468
- coerceEnum(patch.sensitiveTemplates, TEMPLATE_MODES) ??
469
- current.sensitiveTemplates,
470
- sensitivePatterns: unionStrings(
471
- current.sensitivePatterns,
472
- coerceStringList(patch.sensitivePatterns) ?? [],
473
- ),
474
- sensitiveExemptions: unionStrings(
475
- current.sensitiveExemptions,
476
- coerceStringList(patch.sensitiveExemptions) ?? [],
477
- ),
478
- };
479
- wf[FOLDER_POLICY_KEY] = next;
480
- root[WORKFLOW_SETTINGS_KEY] = wf;
481
- fs.mkdirSync(path.dirname(fp), { recursive: true });
482
- fs.writeFileSync(fp, `${JSON.stringify(root, null, 2)}\n`, "utf8");
483
- return { path: fp, ok: true };
484
- } catch (error: unknown) {
485
- return {
486
- path: fp,
487
- ok: false,
488
- error: error instanceof Error ? error.message : String(error),
489
- };
490
- }
491
- }
492
-
493
- // ── Path canonicalisation ────────────────────────────────────────────
494
-
495
- function isCaseInsensitivePlatform(): boolean {
496
- // Only Windows is case-insensitive by default. macOS APFS can be
497
- // case-sensitive; lowercasing there merged distinct paths. realpath handles
498
- // the actual filesystem identity.
499
- return process.platform === "win32";
500
- }
501
-
502
- /** Strip a Windows extended-length prefix from a realpath result. */
503
- export function stripWindowsPrefix(value: string): string {
504
- let out = value;
505
- if (out.startsWith("\\\\?\\UNC\\")) out = `\\\\${out.slice(8)}`;
506
- else if (out.startsWith("\\\\?\\")) out = out.slice(4);
507
- return out;
508
- }
509
-
510
- export function toPosixPath(value: string): string {
511
- return value.replace(/\\/g, "/");
512
- }
513
-
514
- /** Expand a leading `~` (the whole path or `~/` / `~\`). */
515
- export function expandTilde(value: string): string {
516
- if (value === "~") return os.homedir();
517
- if (value.startsWith("~/") || value.startsWith("~\\")) {
518
- return path.join(os.homedir(), value.slice(2));
519
- }
520
- return value;
521
- }
522
-
523
- /** The Pi read tool accepts an `@` prefix; strip it for path matching. */
524
- export function stripAtPrefix(value: string): string {
525
- return value.startsWith("@") ? value.slice(1) : value;
526
- }
527
-
528
- /**
529
- * Canonical comparison form: absolute, forward slashes, platform case, no
530
- * trailing separator (except a filesystem root). Empty input yields "".
531
- */
532
- export function normalizePolicyPath(value: unknown, cwd: string): string {
533
- if (typeof value !== "string") return "";
534
- const raw = stripAtPrefix(expandTilde(value.trim()));
535
- if (!raw) return "";
536
- const foreign = translateForeignPath(raw);
537
- const translated = foreign.path || raw;
538
- const base = cwd && cwd.trim() ? cwd : process.cwd();
539
- let resolved: string;
540
- try {
541
- resolved = path.resolve(base, translated);
542
- } catch {
543
- resolved = translated;
544
- }
545
- let normalized = toPosixPath(stripWindowsPrefix(resolved));
546
- if (isCaseInsensitivePlatform()) normalized = normalized.toLowerCase();
547
- while (
548
- normalized.length > 1 &&
549
- normalized.endsWith("/") &&
550
- !/^[a-z]:\/$/.test(normalized) &&
551
- normalized !== "//"
552
- ) {
553
- normalized = normalized.slice(0, -1);
554
- }
555
- return normalized;
556
- }
557
-
558
- /**
559
- * Resolve symlinks/junctions. If the target does not exist, resolve the
560
- * nearest existing ancestor and append the remainder. `resolved` is false
561
- * when no ancestor could be canonicalised (the caller should treat that as
562
- * an uncertain boundary, not silently trust the lexical path).
563
- */
564
- export function canonicalizePolicyPath(
565
- value: unknown,
566
- cwd: string,
567
- ): { path: string; resolved: boolean } {
568
- const lexical = normalizePolicyPath(value, cwd);
569
- if (!lexical) return { path: "", resolved: false };
570
- const native = lexical.replace(/\//g, path.sep);
571
- try {
572
- const real = fs.realpathSync.native(native);
573
- return { path: normalizePolicyPath(real, cwd), resolved: true };
574
- } catch {
575
- // Walk up to the nearest existing ancestor, then append the remainder.
576
- const parts: string[] = [];
577
- let current = native;
578
- while (true) {
579
- try {
580
- const real = fs.realpathSync.native(current);
581
- const joined = path.join(real, ...parts.reverse());
582
- return { path: normalizePolicyPath(joined, cwd), resolved: true };
583
- } catch {
584
- const parent = path.dirname(current);
585
- if (parent === current) break;
586
- parts.push(path.basename(current));
587
- current = parent;
588
- }
589
- }
590
- return { path: lexical, resolved: false };
591
- }
592
- }
593
-
594
- /** Compare two already-normalised paths on the current platform. */
595
- export function samePolicyPath(a: string, b: string): boolean {
596
- return a === b;
597
- }
598
-
599
- /** True when `target` equals `root` or sits strictly inside it. */
600
- export function isInsidePolicyPath(target: string, root: string): boolean {
601
- if (!target || !root) return false;
602
- if (target === root) return true;
603
- const rel = toPosixPath(path.relative(root, target));
604
- if (!rel) return true;
605
- return rel !== ".." && !rel.startsWith("../") && !path.isAbsolute(rel);
606
- }
607
-
608
- /** True when the target is inside at least one root. */
609
- export function isInsideAnyRoot(
610
- target: string,
611
- roots: readonly string[],
612
- ): boolean {
613
- return roots.some((root) => isInsidePolicyPath(target, root));
614
- }
615
-
616
- /**
617
- * Walk up to the nearest `.git` directory or worktree `.git` file. Returns
618
- * the canonical repository root, or null when cwd is not in a repository.
619
- */
620
- export function detectRepoRoot(cwd: string): string | null {
621
- let current = normalizePolicyPath(cwd, process.cwd());
622
- if (!current) return null;
623
- while (true) {
624
- const marker = current.replace(/\//g, path.sep);
625
- try {
626
- if (fs.existsSync(path.join(marker, ".git"))) {
627
- const canonical = canonicalizePolicyPath(current, cwd);
628
- return canonical.path || current;
629
- }
630
- } catch {
631
- /* keep walking */
632
- }
633
- const parent = path.dirname(marker);
634
- if (parent === marker) return null;
635
- current = normalizePolicyPath(parent, cwd);
636
- if (!current) return null;
637
- }
638
- }
639
-
640
- /** The base folder for a mode (before grants). */
641
- export function baseFolderRoots(
642
- config: FolderPolicyConfig,
643
- cwd: string,
644
- ): string[] {
645
- const working = normalizePolicyPath(cwd, cwd);
646
- const repoBase = detectRepoRoot(cwd) ?? working;
647
- switch (config.mode) {
648
- case "repo":
649
- return [repoBase];
650
- case "cwd":
651
- return [working];
652
- case "parent": {
653
- const parent = path.dirname(repoBase.replace(/\//g, path.sep));
654
- return [normalizePolicyPath(parent, cwd)];
655
- }
656
- case "any":
657
- default:
658
- return [];
659
- }
660
- }
661
-
662
- export interface ResolvedFolderRoots {
663
- lexical: string[];
664
- canonical: string[];
665
- unrestricted: boolean;
666
- }
667
-
668
- /**
669
- * Turn a policy + per-capability grants into comparable roots. When
670
- * `lockedRoots` is supplied (spawned subagent), it replaces the base and the
671
- * grants entirely: the child only gets what the parent approved.
672
- */
673
- export function resolveFolderRoots(
674
- config: FolderPolicyConfig,
675
- grants: readonly string[],
676
- cwd: string,
677
- lockedRoots?: readonly string[],
678
- ): ResolvedFolderRoots {
679
- const raw =
680
- lockedRoots && lockedRoots.length > 0
681
- ? [...lockedRoots]
682
- : [...baseFolderRoots(config, cwd), ...grants];
683
- const lexical: string[] = [];
684
- const canonical: string[] = [];
685
- for (const entry of raw) {
686
- const normalized = normalizePolicyPath(entry, cwd);
687
- if (!normalized) continue;
688
- if (!lexical.includes(normalized)) lexical.push(normalized);
689
- const resolved = canonicalizePolicyPath(normalized, cwd);
690
- if (resolved.path && !canonical.includes(resolved.path))
691
- canonical.push(resolved.path);
692
- }
693
- return {
694
- lexical,
695
- canonical,
696
- unrestricted: config.mode === "any" && !lockedRoots?.length,
697
- };
698
- }
699
-
700
- /**
701
- * The folder a persistent "Always allow" grant should add for a target:
702
- * a directory targets itself, a file targets its containing directory, and a
703
- * glob targets its static prefix. Broad-root refusal is the caller's job.
704
- */
705
- export function grantRootForTarget(target: FolderTarget): string {
706
- const value = target.path;
707
- if (!value) return "";
708
- const wildcard = value.search(/[?*[]/);
709
- if (wildcard >= 0) {
710
- const prefix = value.slice(0, wildcard).replace(/\/+$/, "");
711
- return prefix || value;
712
- }
713
- if (target.kind === "dir" || target.kind === "repo") return value;
714
- return toPosixPath(path.dirname(value.replace(/\//g, path.sep)));
715
- }
716
-
717
- /**
718
- * The “outside project” folder a grant should cover: the first path segment
719
- * below the deepest common ancestor of the base and the target. A file that
720
- * sits directly in that common ancestor is granted as the file itself; an
721
- * outside target that is an ancestor of the base is granted as itself.
722
- *
723
- * Returns undefined for a target inside the base (the caller keeps its
724
- * capability-specific grant, e.g. an exact write path).
725
- */
726
- export function outsideProjectRootForTarget(
727
- target: FolderTarget,
728
- baseRoot: string,
729
- ): string | undefined {
730
- const base = normalizePolicyPath(baseRoot, baseRoot);
731
- const normalized = normalizePolicyPath(target.path, base || process.cwd());
732
- if (!base || !normalized) return undefined;
733
- if (isInsidePolicyPath(normalized, base)) return undefined;
734
- // A repository root above a cwd-mode session is itself the outside folder.
735
- if (isInsidePolicyPath(base, normalized)) return normalized;
736
-
737
- const baseParts = base.split("/").filter(Boolean);
738
- const targetParts = normalized.split("/").filter(Boolean);
739
- let common = 0;
740
- while (
741
- common < baseParts.length &&
742
- common < targetParts.length &&
743
- baseParts[common] === targetParts[common]
744
- ) {
745
- common++;
746
- }
747
- const commonRoot = baseParts.slice(0, common).join("/");
748
- const remaining = targetParts.slice(common);
749
- if (remaining.length === 0) return normalized;
750
- // Directly in the common ancestor: a file stays the file; a directory is
751
- // the directory; a glob/static prefix stays its prefix.
752
- if (remaining.length === 1) return normalized;
753
- const child = remaining[0];
754
- return commonRoot ? `${commonRoot}/${child}` : `/${child}`;
755
- }
756
-
757
- /** A broad grant: filesystem root, home, drive root, or depth ≤ 1. */
758
- export function isBroadGrantRoot(value: string): boolean {
759
- const normalized = normalizePolicyPath(value, process.cwd());
760
- if (!normalized) return true;
761
- const root = normalizePolicyPath(path.parse(normalized).root, process.cwd());
762
- if (normalized === root) return true;
763
- const home = normalizePolicyPath(os.homedir(), process.cwd());
764
- if (home && (normalized === home || isInsidePolicyPath(home, normalized)))
765
- return true;
766
- const rel = toPosixPath(path.relative(root, normalized));
767
- const depth = rel.split("/").filter(Boolean).length;
768
- return depth <= 1;
769
- }
770
-
771
- /** UNC / network share detection (Windows and POSIX). */
772
- export function isNetworkPath(value: string): boolean {
773
- const normalized = toPosixPath(value);
774
- if (normalized.startsWith("//")) return true;
775
- if (/^\/net\//.test(normalized)) return true;
776
- if (/^\\\\[^\\]/.test(value)) return true;
777
- return false;
778
- }
779
-
780
- // ── Sensitive matching ───────────────────────────────────────────────
781
-
782
- function escapeRegExp(value: string): string {
783
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
784
- }
785
-
786
- function globCoreToRegExp(pattern: string): string {
787
- let out = "";
788
- for (let i = 0; i < pattern.length; i++) {
789
- const ch = pattern[i];
790
- if (ch === "*") {
791
- if (pattern[i + 1] === "*") {
792
- i++;
793
- if (pattern[i + 1] === "/") {
794
- i++;
795
- out += "(?:.*/)?";
796
- } else out += ".*";
797
- } else out += "[^/]*";
798
- } else if (ch === "?") out += "[^/]";
799
- else out += escapeRegExp(ch);
800
- }
801
- return out;
802
- }
803
-
804
- /** Compile one sensitive glob. Patterns are matched as path suffixes. */
805
- export function globToRegExp(pattern: string): RegExp {
806
- let source = toPosixPath(pattern.trim());
807
- if (source.startsWith("./")) source = source.slice(2);
808
- if (!source) return /$^/;
809
- if (!source.includes("/")) {
810
- return new RegExp(`(?:^|/)${globCoreToRegExp(source)}$`, "i");
811
- }
812
- if (source.startsWith("**/")) source = source.slice(3);
813
- if (source.endsWith("/**")) {
814
- const head = source.slice(0, -3);
815
- return new RegExp(`(?:^|/)${globCoreToRegExp(head)}(?:/.*)?$`, "i");
816
- }
817
- return new RegExp(`(?:^|/)${globCoreToRegExp(source)}$`, "i");
818
- }
819
-
820
- export interface SensitiveMatch {
821
- sensitive: boolean;
822
- pattern?: string;
823
- path?: string;
824
- }
825
-
826
- function patternMatches(
827
- pattern: string,
828
- lexical: string,
829
- canonical: string,
830
- cwd: string,
831
- ): boolean {
832
- const re = globToRegExp(pattern);
833
- const rel = toPosixPath(path.relative(cwd, lexical));
834
- const candidates = [lexical, canonical, rel];
835
- return candidates.some((candidate) => candidate && re.test(candidate));
836
- }
837
-
838
- /** The effective deny patterns (defaults + extra, exemptions applied later). */
839
- export function effectiveSensitivePatterns(
840
- config: FolderPolicyConfig,
841
- ): string[] {
842
- return unionStrings(DEFAULT_SENSITIVE_PATTERNS, config.sensitivePatterns);
843
- }
844
-
845
- /** The effective exemptions (default templates + manual exemptions). */
846
- export function effectiveSensitiveExemptions(
847
- config: FolderPolicyConfig,
848
- ): string[] {
849
- const templates =
850
- config.sensitiveTemplates === "allow" ? [...DEFAULT_SENSITIVE_EXEMPTIONS] : [];
851
- return unionStrings(templates, config.sensitiveExemptions);
852
- }
853
-
854
- /** Match one path against the shared sensitive list. */
855
- export function matchSensitivePath(
856
- value: unknown,
857
- config: FolderPolicyConfig,
858
- cwd: string,
859
- ): SensitiveMatch {
860
- const lexical = normalizePolicyPath(value, cwd);
861
- if (!lexical) return { sensitive: false };
862
- const canonical = canonicalizePolicyPath(lexical, cwd).path || lexical;
863
- for (const exemption of effectiveSensitiveExemptions(config)) {
864
- if (patternMatches(exemption, lexical, canonical, cwd))
865
- return { sensitive: false };
866
- }
867
- for (const pattern of effectiveSensitivePatterns(config)) {
868
- if (patternMatches(pattern, lexical, canonical, cwd)) {
869
- return { sensitive: true, pattern, path: lexical };
870
- }
871
- }
872
- return { sensitive: false };
873
- }
874
-
875
- // ── Folder evaluation / decision ─────────────────────────────────────
876
-
877
- export type FolderTargetKind = "file" | "dir" | "repo" | "glob";
878
-
879
- export interface FolderTarget {
880
- path: string;
881
- kind: FolderTargetKind;
882
- reason?: string;
883
- }
884
-
885
- export interface SensitiveTarget {
886
- path: string;
887
- pattern: string;
888
- }
889
-
890
- export interface FolderEvaluation {
891
- sensitive: SensitiveTarget[];
892
- outside: FolderTarget[];
893
- allowed: FolderTarget[];
894
- network: FolderTarget[];
895
- broad: FolderTarget[];
896
- uncertain: string[];
897
- }
898
-
899
- export interface FolderEvaluationOptions {
900
- config: FolderPolicyConfig;
901
- grants: readonly string[];
902
- cwd: string;
903
- /** Parent-approved roots for a locked subagent. */
904
- lockedRoots?: readonly string[];
905
- /** Only used for diagnostics; containment is the same for all actions. */
906
- action?: "read" | "write" | "commit";
907
- }
908
-
909
- function targetNormalized(target: FolderTarget, cwd: string): string {
910
- return normalizePolicyPath(target.path, cwd);
911
- }
912
-
913
- function isAllowedByRoots(
914
- lexical: string,
915
- canonical: string,
916
- roots: ResolvedFolderRoots,
917
- ): boolean {
918
- if (roots.unrestricted) return true;
919
- // Both the requested path and the resolved path must be inside a root.
920
- if (!isInsideAnyRoot(lexical, roots.lexical)) return false;
921
- if (canonical && !isInsideAnyRoot(canonical, roots.canonical)) return false;
922
- return true;
923
- }
924
-
925
- /**
926
- * Classify targets against the folder floor. Sensitive wins; network paths
927
- * need an explicit grant even in `any` mode; canonicalisation failures are
928
- * reported so the caller can ask instead of silently trusting them.
929
- */
930
- export function evaluateFolderTargets(
931
- targets: readonly FolderTarget[],
932
- options: FolderEvaluationOptions,
933
- ): FolderEvaluation {
934
- const { config, grants, cwd, lockedRoots } = options;
935
- const roots = resolveFolderRoots(config, grants, cwd, lockedRoots);
936
- const evaluation: FolderEvaluation = {
937
- sensitive: [],
938
- outside: [],
939
- allowed: [],
940
- network: [],
941
- broad: [],
942
- uncertain: [],
943
- };
944
- for (const target of targets) {
945
- const lexical = targetNormalized(target, cwd);
946
- if (!lexical) {
947
- evaluation.uncertain.push("empty path");
948
- continue;
949
- }
950
- if (translateForeignPath(target.path).uncertain)
951
- evaluation.uncertain.push(lexical);
952
- const canonicalResult = canonicalizePolicyPath(lexical, cwd);
953
- const canonical = canonicalResult.path || lexical;
954
- const sensitive = matchSensitivePath(lexical, config, cwd);
955
- if (sensitive.sensitive) {
956
- evaluation.sensitive.push({
957
- path: lexical,
958
- pattern: sensitive.pattern ?? "sensitive",
959
- });
960
- continue;
961
- }
962
- if (isBroadGrantRoot(lexical)) evaluation.broad.push(target);
963
- const network = isNetworkPath(lexical) || isNetworkPath(canonical);
964
- if (network) evaluation.network.push(target);
965
- // Network paths need an explicit per-capability grant even in `any` mode.
966
- const networkGranted =
967
- isInsideAnyRoot(lexical, roots.lexical) ||
968
- (Boolean(canonical) && isInsideAnyRoot(canonical, roots.canonical));
969
- const allowed = network
970
- ? networkGranted
971
- : isAllowedByRoots(lexical, canonical, roots);
972
- if (!canonicalResult.resolved) {
973
- evaluation.uncertain.push(lexical);
974
- if (!allowed) evaluation.outside.push(target);
975
- else evaluation.allowed.push(target);
976
- continue;
977
- }
978
- if (allowed) evaluation.allowed.push(target);
979
- else evaluation.outside.push(target);
980
- }
981
- return evaluation;
982
- }
983
-
984
- export type FolderGateDecision = "allow" | "ask" | "block";
985
-
986
- export interface FolderGateInput {
987
- sensitive: boolean;
988
- outside: boolean;
989
- outsideAction: FolderOutsideAction;
990
- /** An allow-once/always grant already covers every outside target. */
991
- granted?: boolean;
992
- network?: boolean;
993
- uncertain?: boolean;
994
- }
995
-
996
- /** Pure folder decision. Sensitive always wins; policy can never loosen it. */
997
- export function decideFolderGate(input: FolderGateInput): FolderGateDecision {
998
- if (input.sensitive) return "block";
999
- if (input.outside) {
1000
- if (input.outsideAction === "block") return "block";
1001
- if (!input.granted) return "ask";
1002
- }
1003
- if (input.network && !input.granted) return "ask";
1004
- if (input.uncertain) {
1005
- // An unverifiable target follows the user's chosen outside action: a
1006
- // policy of "block" must not open a dialog, and "ask" must ask.
1007
- return input.outsideAction === "block" ? "block" : "ask";
1008
- }
1009
- return "allow";
1010
- }
1011
-
1012
- /** Most restrictive wins: block > ask > allow. */
1013
- export function combineGateDecisions(
1014
- ...decisions: readonly (FolderGateDecision | undefined)[]
1015
- ): FolderGateDecision {
1016
- let result: FolderGateDecision = "allow";
1017
- for (const decision of decisions) {
1018
- if (decision === "block") return "block";
1019
- if (decision === "ask") result = "ask";
1020
- }
1021
- return result;
1022
- }
1023
-
1024
- /** One-line human-readable summary of the folder mode. */
1025
- export function describeFolderPolicyMode(mode: FolderPolicyMode): string {
1026
- switch (mode) {
1027
- case "repo":
1028
- return "the current repository root (or the working directory when not in git)";
1029
- case "cwd":
1030
- return "the session working directory only";
1031
- case "parent":
1032
- return "the parent of the repository/working-directory base (siblings)";
1033
- case "any":
1034
- return "any folder (sensitive files still blocked)";
1035
- default:
1036
- return "the current repository root";
1037
- }
1038
- }
1039
-
1040
- /**
1041
- * Human-readable assessment of one folder evaluation, for `/permissions
1042
- * explain` and the folder status output. Bounded so a broad command cannot
1043
- * flood the transcript; `` allowed/outside/sensitive/uncertain counts are
1044
- * always shown.
1045
- */
1046
- export function describeFolderEvaluation(
1047
- evaluation: FolderEvaluation,
1048
- base: string,
1049
- ): string[] {
1050
- const MAX = 8;
1051
- const lines = [
1052
- `Folder base: ${base}`,
1053
- ` targets: ${evaluation.allowed.length} inside, ${evaluation.outside.length} outside, ${evaluation.sensitive.length} sensitive, ${evaluation.uncertain.length} unverifiable`,
1054
- ];
1055
- const add = (label: string, values: readonly string[]) => {
1056
- for (const value of values.slice(0, MAX)) lines.push(` ${label}: ${value}`);
1057
- if (values.length > MAX) {
1058
- lines.push(` ${label}: … and ${values.length - MAX} more`);
1059
- }
1060
- };
1061
- add(
1062
- "inside",
1063
- evaluation.allowed.map((target) => target.path),
1064
- );
1065
- add(
1066
- "outside",
1067
- evaluation.outside.map((target) => target.path),
1068
- );
1069
- add(
1070
- "network",
1071
- evaluation.network.map((target) => target.path),
1072
- );
1073
- add(
1074
- "sensitive",
1075
- evaluation.sensitive.map((hit) => `${hit.path} (${hit.pattern})`),
1076
- );
1077
- add("unverifiable", evaluation.uncertain);
1078
- return lines;
1079
- }
1080
-
1081
- // ── /folder-policy completions ───────────────────────────────────────
1082
-
1083
- const FOLDER_POLICY_SUBS: readonly {
1084
- name: string;
1085
- description: string;
1086
- }[] = [
1087
- { name: "status", description: "show mode, outside, grants and sensitive rules" },
1088
- { name: "repo", description: "only the nearest git repository root (default)" },
1089
- { name: "cwd", description: "only the session working directory" },
1090
- { name: "parent", description: "the parent folder (siblings) as well" },
1091
- { name: "any", description: "no folder containment; sensitive files still blocked" },
1092
- { name: "outside", description: "ask or block reads/writes/commits outside the roots" },
1093
- { name: "grant", description: "project or global for 'Always allow' grants" },
1094
- { name: "templates", description: "allow or block .env.example-style templates" },
1095
- { name: "sensitive", description: "list or extend the sensitive deny list" },
1096
- ];
1097
-
1098
- const FOLDER_SCOPES: readonly string[] = ["global", "project"];
1099
-
1100
- /**
1101
- * Full-argument completions for `/folder-policy`, following pi's contract:
1102
- * accepting a suggestion replaces the entire argument text.
1103
- */
1104
- export function folderPolicyArgumentCompletions(
1105
- prefix: string,
1106
- ): ArgumentCompletion[] | null {
1107
- const text = String(prefix ?? "");
1108
- const trailing = /\s$/.test(text);
1109
- const tokens = text.trim().split(/\s+/).filter(Boolean);
1110
- const committed = trailing ? tokens : tokens.slice(0, -1);
1111
- const partial = trailing ? "" : (tokens[tokens.length - 1] ?? "");
1112
- const first = committed[0] ?? "";
1113
-
1114
- const candidates: { value: string; label: string; description?: string }[] =
1115
- [];
1116
- const add = (value: string, description?: string) =>
1117
- candidates.push({ value, label: value, description });
1118
-
1119
- if (committed.length === 0) {
1120
- for (const sub of FOLDER_POLICY_SUBS) add(sub.name, sub.description);
1121
- } else if (committed.length === 1) {
1122
- if (first === "outside") {
1123
- add("ask", "prompt once per turn; headless fails closed");
1124
- add("block", "refuse outside without asking");
1125
- } else if (first === "grant") {
1126
- add("project", "persist new grants in <cwd>/.pi/settings.json");
1127
- add("global", "persist new grants in ~/.pi/agent/settings.json");
1128
- } else if (first === "templates") {
1129
- add("allow", "allow .env.example/.sample/.template/.dist");
1130
- add("block", "treat templates as sensitive");
1131
- } else if (first === "sensitive") {
1132
- add("add", "add an extra sensitive glob");
1133
- add("allow", "add a manual exemption glob");
1134
- } else if (["repo", "cwd", "parent", "any"].includes(first)) {
1135
- for (const scope of FOLDER_SCOPES) add(scope);
1136
- }
1137
- } else if (committed.length === 2) {
1138
- if (["outside", "grant", "templates", "sensitive"].includes(first)) {
1139
- for (const scope of FOLDER_SCOPES) add(scope);
1140
- }
1141
- }
1142
-
1143
- const filtered = candidates.filter((candidate) =>
1144
- candidate.value.toLowerCase().startsWith(partial.toLowerCase()),
1145
- );
1146
- if (filtered.length === 0) return null;
1147
- if (
1148
- suppressExactArgumentMatch(
1149
- filtered.map((candidate) => candidate.value),
1150
- partial,
1151
- )
1152
- )
1153
- return null;
1154
- return filtered.map((candidate) => ({
1155
- value: joinArgumentValue(text, candidate.value),
1156
- label: candidate.label,
1157
- ...(candidate.description ? { description: candidate.description } : {}),
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
+ }