@dieulc/workflow 0.10.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/edit-policy.ts CHANGED
@@ -33,13 +33,22 @@
33
33
  import * as fs from "node:fs";
34
34
  import * as path from "node:path";
35
35
  import type { ArgumentCompletion } from "./utils.ts";
36
- import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
36
+ import {
37
+ joinArgumentValue,
38
+ suppressExactArgumentMatch,
39
+ translateForeignPath,
40
+ } from "./utils.ts";
37
41
  import {
38
42
  COMMIT_CONFIG_DIR,
39
43
  WORKFLOW_SETTINGS_KEY,
40
44
  type JsonObject,
41
45
  } from "./commit-policy.ts";
42
46
  import { analyzeCommand } from "./permissions.ts";
47
+ import {
48
+ isInsidePolicyPath,
49
+ normalizePolicyPath,
50
+ type FolderGateDecision,
51
+ } from "./folder-policy.ts";
43
52
 
44
53
  /** Nested key under WORKFLOW_SETTINGS_KEY. */
45
54
  export const WRITE_POLICY_KEY = "writePolicy";
@@ -193,9 +202,11 @@ export function writePolicyConfigPaths(
193
202
  cwd: string,
194
203
  agentDir: string,
195
204
  ): WritePolicyConfigPaths {
205
+ const nativeCwd = translateForeignPath(cwd).path || cwd;
206
+ const nativeAgentDir = translateForeignPath(agentDir).path || agentDir;
196
207
  return {
197
- global: path.join(agentDir, "settings.json"),
198
- project: path.join(cwd, COMMIT_CONFIG_DIR, "settings.json"),
208
+ global: path.join(nativeAgentDir, "settings.json"),
209
+ project: path.join(nativeCwd, COMMIT_CONFIG_DIR, "settings.json"),
199
210
  };
200
211
  }
201
212
 
@@ -250,22 +261,33 @@ export interface ResolvedWritePolicy {
250
261
  * Resolve the effective Write Policy config from disk.
251
262
  * Never throws — a corrupt or missing file falls back to defaults.
252
263
  */
264
+ export interface WritePolicyReadOptions {
265
+ /** Project settings are ignored unless explicitly trusted. */
266
+ trusted?: boolean;
267
+ }
268
+
253
269
  export function readWritePolicyConfig(
254
270
  cwd: string,
255
271
  agentDir: string,
272
+ options: WritePolicyReadOptions = {},
256
273
  ): ResolvedWritePolicy {
257
274
  const paths = writePolicyConfigPaths(cwd, agentDir);
258
275
  const globalSettings = readJsonSafe(paths.global);
259
276
  const projectSettings = readJsonSafe(paths.project);
260
277
  const globalRaw = extractWritePolicyRaw(globalSettings);
261
278
  const projectRaw = extractWritePolicyRaw(projectSettings);
262
- const config = mergeWritePolicyConfig(globalRaw, projectRaw);
279
+ // Trust is fail-closed for callers that do not say otherwise. The long-lived
280
+ // gate passes ctx.isProjectTrusted(); tests that exercise project settings
281
+ // explicitly opt in.
282
+ const trusted = options.trusted === true;
283
+ const effectiveProjectRaw = trusted ? projectRaw : undefined;
284
+ const config = mergeWritePolicyConfig(globalRaw, effectiveProjectRaw);
263
285
  return {
264
286
  config,
265
287
  sources: {
266
288
  global: globalRaw !== undefined,
267
289
  project: projectRaw !== undefined,
268
- mode: writePolicyModeSourceOf(globalRaw, projectRaw),
290
+ mode: writePolicyModeSourceOf(globalRaw, effectiveProjectRaw),
269
291
  },
270
292
  };
271
293
  }
@@ -288,6 +310,9 @@ export function writeWritePolicyConfig(
288
310
  ): { path: string; ok: boolean; error?: string } {
289
311
  const paths = writePolicyConfigPaths(cwd, agentDir);
290
312
  const fp = scope === "project" ? paths.project : paths.global;
313
+ if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
314
+ return { path: fp, ok: false, error: "write policy is locked for this subagent" };
315
+ }
291
316
  try {
292
317
  const existing = readJsonSafe(fp);
293
318
  const root: JsonObject = existing ? { ...existing } : {};
@@ -342,25 +367,7 @@ export function writeWritePolicyConfig(
342
367
  * its own path compare equal; the filesystem root keeps its slash.
343
368
  */
344
369
  export function normalizeWritePath(p: unknown, cwd: string): string {
345
- if (typeof p !== "string") return "";
346
- const raw = p.trim();
347
- if (!raw) return "";
348
- const base = cwd && cwd.trim() ? cwd : process.cwd();
349
- let resolved: string;
350
- try {
351
- resolved = path.resolve(base, raw);
352
- } catch {
353
- resolved = raw;
354
- }
355
- let normalized = resolved.replace(/\\/g, "/").toLowerCase();
356
- while (
357
- normalized.length > 1 &&
358
- normalized.endsWith("/") &&
359
- !/^[a-z]:\/$/.test(normalized)
360
- ) {
361
- normalized = normalized.slice(0, -1);
362
- }
363
- return normalized;
370
+ return normalizePolicyPath(p, cwd);
364
371
  }
365
372
 
366
373
  /**
@@ -380,8 +387,7 @@ export function isPathAllowed(
380
387
  for (const entry of allowedPaths) {
381
388
  const normalizedEntry = normalizeWritePath(entry, cwd);
382
389
  if (!normalizedEntry) continue;
383
- if (normalizedTarget === normalizedEntry) return true;
384
- if (normalizedTarget.startsWith(`${normalizedEntry}/`)) return true;
390
+ if (isInsidePolicyPath(normalizedTarget, normalizedEntry)) return true;
385
391
  }
386
392
  return false;
387
393
  }
@@ -591,6 +597,8 @@ export interface WriteGateInput {
591
597
  mutating: boolean;
592
598
  /** The call is already covered by a persistent grant. */
593
599
  allowed: boolean;
600
+ /** The shared folder floor's verdict (default allow for legacy callers). */
601
+ folder?: FolderGateDecision;
594
602
  }
595
603
 
596
604
  /**
@@ -607,8 +615,11 @@ export interface WriteGateInput {
607
615
  */
608
616
  export function decideWriteGate(input: WriteGateInput): WriteGateDecision {
609
617
  if (!input.mutating) return "allow";
610
- if (input.mode === "allow") return "allow";
618
+ const folder = input.folder ?? "allow";
619
+ if (folder === "block") return "block";
611
620
  if (input.mode === "never") return "block";
621
+ if (input.mode === "allow") return folder === "ask" ? "ask" : "allow";
622
+ if (folder === "ask") return "ask";
612
623
  return input.allowed ? "allow" : "ask";
613
624
  }
614
625
 
@@ -708,10 +719,11 @@ export function sanitizeWriteInstruction(raw: unknown): string {
708
719
  */
709
720
  export function writeGateOptions(
710
721
  kind: WriteGateOptionKind,
722
+ opts: { allowAlways?: boolean } = {},
711
723
  ): readonly string[] {
712
724
  return [
713
725
  "Allow once (this turn)",
714
- writeGateAlwaysLabel(kind),
726
+ ...(opts.allowAlways !== false ? [writeGateAlwaysLabel(kind)] : []),
715
727
  "Deny",
716
728
  WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
717
729
  ];
@@ -729,6 +741,10 @@ export function writeGateAlwaysLabel(kind: WriteGateOptionKind): string {
729
741
  return "Always allow this path";
730
742
  case "paths":
731
743
  return "Always allow these paths";
744
+ case "folder":
745
+ return "Always allow this folder";
746
+ case "folders":
747
+ return "Always allow these folders";
732
748
  case "command":
733
749
  return "Always allow this command";
734
750
  case "commands":
@@ -772,6 +788,10 @@ export interface WriteDenyReasonInput {
772
788
  why?: string;
773
789
  /** The user's instruction; sanitized here. Empty → a plain denial. */
774
790
  instruction?: string;
791
+ /** Which policy asked; a folder refusal must not blame the write policy. */
792
+ source?: WriteGateSource;
793
+ /** Base folder for a folder-sourced denial. */
794
+ outside?: string;
775
795
  }
776
796
 
777
797
  /**
@@ -787,16 +807,23 @@ export interface WriteDenyReasonInput {
787
807
  export function buildWriteDenyReason(input: WriteDenyReasonInput): string {
788
808
  const target = String(input.target ?? "").trim() || "the requested write";
789
809
  const why = input.why ? ` (${input.why})` : "";
810
+ const policyName =
811
+ input.source === "folder"
812
+ ? "Folder policy 'ask'"
813
+ : input.source === "mixed"
814
+ ? "Permission policy 'ask'"
815
+ : "Write policy 'ask'";
816
+ const outside = input.outside ? ` outside ${input.outside}` : "";
790
817
  const instruction = sanitizeWriteInstruction(input.instruction);
791
818
  if (!instruction) {
792
819
  return (
793
- `Write policy 'ask': the user declined this write${why}. Do not retry it. ` +
820
+ `${policyName}: the user declined this write${outside}${why}. Do not retry it. ` +
794
821
  "Propose the change (a diff or the exact edit) and let the user apply it, or ask whether to continue another way. " +
795
822
  `Target: ${target}`
796
823
  );
797
824
  }
798
825
  return (
799
- `Write policy 'ask': the user denied this write${why} and gave this instruction:\n\n` +
826
+ `${policyName}: the user denied this write${outside}${why} and gave this instruction:\n\n` +
800
827
  `${instruction}\n\n` +
801
828
  "Do not retry this write. Follow the instruction above. If it cannot be followed, say so and stop. " +
802
829
  `Target: ${target}`
@@ -853,20 +880,29 @@ export interface WriteGateItem {
853
880
  key: string;
854
881
  /** Path grants "always allow" would persist for this item. */
855
882
  grantPaths?: string[];
883
+ /** Whether those path grants cover files or an outside folder. */
884
+ grantKind?: "file" | "folder";
856
885
  /** Command (or tool) grant "always allow" would persist for this item. */
857
886
  grantCommand?: string;
858
887
  }
859
888
 
889
+ /** Which policy produced the ask: the write mode, the folder floor, or both. */
890
+ export type WriteGateSource = "write" | "folder" | "mixed";
891
+
860
892
  /**
861
893
  * Which "always allow" label fits a set of items.
862
894
  *
863
- * "path"/"paths" — every item writes files; "command"/"commands" — every
864
- * item runs a command (or a path-less tool, whose grant is stored in the
865
- * command list); "mixed" — both in one turn.
895
+ * "path"/"paths" — every item writes files; "folder"/"folders" — every
896
+ * path grant comes from the shared folder floor and covers a folder;
897
+ * "command"/"commands" — every item runs a command (or a path-less tool,
898
+ * whose grant is stored in the command list); "mixed" — more than one kind
899
+ * in one turn.
866
900
  */
867
901
  export type WriteGateOptionKind =
868
902
  | "path"
869
903
  | "paths"
904
+ | "folder"
905
+ | "folders"
870
906
  | "command"
871
907
  | "commands"
872
908
  | "mixed";
@@ -917,8 +953,10 @@ export function writeGateItemFromClassification(
917
953
  why: classification.reason,
918
954
  key: writeGateTargetKey(tool, classification, cwd),
919
955
  };
920
- if (grantPaths.length > 0) item.grantPaths = grantPaths;
921
- else if (classification.key) item.grantCommand = classification.key;
956
+ if (grantPaths.length > 0) {
957
+ item.grantPaths = grantPaths;
958
+ item.grantKind = "file";
959
+ } else if (classification.key) item.grantCommand = classification.key;
922
960
  return item;
923
961
  }
924
962
 
@@ -929,7 +967,12 @@ export function writeGateItemsKind(
929
967
  const paths = items.filter((i) => (i.grantPaths?.length ?? 0) > 0);
930
968
  const commands = items.filter((i) => Boolean(i.grantCommand));
931
969
  if (paths.length > 0 && commands.length > 0) return "mixed";
932
- if (paths.length > 0) return paths.length === 1 ? "path" : "paths";
970
+ if (paths.length > 0) {
971
+ if (paths.every((i) => i.grantKind === "folder")) {
972
+ return paths.length === 1 ? "folder" : "folders";
973
+ }
974
+ return paths.length === 1 ? "path" : "paths";
975
+ }
933
976
  if (commands.length > 0) return commands.length === 1 ? "command" : "commands";
934
977
  return "path";
935
978
  }
@@ -966,19 +1009,52 @@ export function writeGateTargetsLabel(
966
1009
  /**
967
1010
  * The dialog title.
968
1011
  *
969
- * One write keeps today's wording verbatim; several writes are listed, so
970
- * the user sees exactly what the answer covers.
1012
+ * The source is the policy that actually produced the ask: a folder-floor
1013
+ * prompt must not claim the write policy asked (that confusion is what made
1014
+ * the in-repo toolchain regression look like a write-policy bug). One write
1015
+ * keeps today's wording verbatim; several writes are listed.
971
1016
  */
972
- export function writeGateTitle(items: readonly WriteGateItem[]): string {
1017
+ export function writeGateTitle(
1018
+ items: readonly WriteGateItem[],
1019
+ source: WriteGateSource = "write",
1020
+ base?: string,
1021
+ opts: { grantBlockedReason?: string } = {},
1022
+ ): string {
1023
+ const outside =
1024
+ source === "folder" && base ? `\nOutside: ${base}` : "";
1025
+ const blocked = opts.grantBlockedReason
1026
+ ? `\nGrant not available: ${opts.grantBlockedReason}`
1027
+ : "";
1028
+ const header =
1029
+ source === "folder"
1030
+ ? "Folder policy 'ask': allow this write outside the permitted folders?"
1031
+ : source === "mixed"
1032
+ ? "Permission required: allow this write? (write policy and folder policy are 'ask')"
1033
+ : "Write policy 'ask': allow this write?";
973
1034
  if (items.length <= 1) {
974
1035
  const only = items[0];
975
- const why = only?.why ? ` (${only.why})` : "";
976
- return `Write policy 'ask': allow this write${why}?`;
1036
+ const target = String(only?.target ?? "").trim();
1037
+ const reason = String(only?.why ?? "").trim();
1038
+ return (
1039
+ header +
1040
+ outside +
1041
+ (target ? `\nTarget: ${target}` : "") +
1042
+ (reason && reason !== target ? `\nReason: ${reason}` : "") +
1043
+ blocked
1044
+ );
977
1045
  }
978
- return (
979
- `Write policy 'ask': allow these ${items.length} writes? ` +
980
- `(${describeWriteGateItems(items)})`
981
- );
1046
+ const lines = items
1047
+ .slice(0, 5)
1048
+ .map((item) => ` • ${String(item.target ?? "").trim()}`)
1049
+ .filter((line) => line.trim().length > 3);
1050
+ if (items.length > 5) lines.push(` … and ${items.length - 5} more`);
1051
+ const many =
1052
+ source === "folder"
1053
+ ? `Folder policy 'ask': allow these ${items.length} writes outside the permitted folders?`
1054
+ : source === "mixed"
1055
+ ? `Permission required: allow these ${items.length} writes? (write policy and folder policy are 'ask')`
1056
+ : `Write policy 'ask': allow these ${items.length} writes?`;
1057
+ return `${many}${outside}\n${lines.join("\n")}${blocked}`;
982
1058
  }
983
1059
 
984
1060
  // ── Argument completions (/write-policy) ────────────────────────────