@dieulc/workflow 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ * 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
+ }