@dieulc/workflow 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -342,25 +364,7 @@ export function writeWritePolicyConfig(
342
364
  * its own path compare equal; the filesystem root keeps its slash.
343
365
  */
344
366
  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;
367
+ return normalizePolicyPath(p, cwd);
364
368
  }
365
369
 
366
370
  /**
@@ -380,8 +384,7 @@ export function isPathAllowed(
380
384
  for (const entry of allowedPaths) {
381
385
  const normalizedEntry = normalizeWritePath(entry, cwd);
382
386
  if (!normalizedEntry) continue;
383
- if (normalizedTarget === normalizedEntry) return true;
384
- if (normalizedTarget.startsWith(`${normalizedEntry}/`)) return true;
387
+ if (isInsidePolicyPath(normalizedTarget, normalizedEntry)) return true;
385
388
  }
386
389
  return false;
387
390
  }
@@ -591,6 +594,8 @@ export interface WriteGateInput {
591
594
  mutating: boolean;
592
595
  /** The call is already covered by a persistent grant. */
593
596
  allowed: boolean;
597
+ /** The shared folder floor's verdict (default allow for legacy callers). */
598
+ folder?: FolderGateDecision;
594
599
  }
595
600
 
596
601
  /**
@@ -607,8 +612,11 @@ export interface WriteGateInput {
607
612
  */
608
613
  export function decideWriteGate(input: WriteGateInput): WriteGateDecision {
609
614
  if (!input.mutating) return "allow";
610
- if (input.mode === "allow") return "allow";
615
+ const folder = input.folder ?? "allow";
616
+ if (folder === "block") return "block";
611
617
  if (input.mode === "never") return "block";
618
+ if (input.mode === "allow") return folder === "ask" ? "ask" : "allow";
619
+ if (folder === "ask") return "ask";
612
620
  return input.allowed ? "allow" : "ask";
613
621
  }
614
622
 
@@ -729,6 +737,10 @@ export function writeGateAlwaysLabel(kind: WriteGateOptionKind): string {
729
737
  return "Always allow this path";
730
738
  case "paths":
731
739
  return "Always allow these paths";
740
+ case "folder":
741
+ return "Always allow this folder";
742
+ case "folders":
743
+ return "Always allow these folders";
732
744
  case "command":
733
745
  return "Always allow this command";
734
746
  case "commands":
@@ -772,6 +784,10 @@ export interface WriteDenyReasonInput {
772
784
  why?: string;
773
785
  /** The user's instruction; sanitized here. Empty → a plain denial. */
774
786
  instruction?: string;
787
+ /** Which policy asked; a folder refusal must not blame the write policy. */
788
+ source?: WriteGateSource;
789
+ /** Base folder for a folder-sourced denial. */
790
+ outside?: string;
775
791
  }
776
792
 
777
793
  /**
@@ -787,16 +803,23 @@ export interface WriteDenyReasonInput {
787
803
  export function buildWriteDenyReason(input: WriteDenyReasonInput): string {
788
804
  const target = String(input.target ?? "").trim() || "the requested write";
789
805
  const why = input.why ? ` (${input.why})` : "";
806
+ const policyName =
807
+ input.source === "folder"
808
+ ? "Folder policy 'ask'"
809
+ : input.source === "mixed"
810
+ ? "Permission policy 'ask'"
811
+ : "Write policy 'ask'";
812
+ const outside = input.outside ? ` outside ${input.outside}` : "";
790
813
  const instruction = sanitizeWriteInstruction(input.instruction);
791
814
  if (!instruction) {
792
815
  return (
793
- `Write policy 'ask': the user declined this write${why}. Do not retry it. ` +
816
+ `${policyName}: the user declined this write${outside}${why}. Do not retry it. ` +
794
817
  "Propose the change (a diff or the exact edit) and let the user apply it, or ask whether to continue another way. " +
795
818
  `Target: ${target}`
796
819
  );
797
820
  }
798
821
  return (
799
- `Write policy 'ask': the user denied this write${why} and gave this instruction:\n\n` +
822
+ `${policyName}: the user denied this write${outside}${why} and gave this instruction:\n\n` +
800
823
  `${instruction}\n\n` +
801
824
  "Do not retry this write. Follow the instruction above. If it cannot be followed, say so and stop. " +
802
825
  `Target: ${target}`
@@ -853,20 +876,29 @@ export interface WriteGateItem {
853
876
  key: string;
854
877
  /** Path grants "always allow" would persist for this item. */
855
878
  grantPaths?: string[];
879
+ /** Whether those path grants cover files or an outside folder. */
880
+ grantKind?: "file" | "folder";
856
881
  /** Command (or tool) grant "always allow" would persist for this item. */
857
882
  grantCommand?: string;
858
883
  }
859
884
 
885
+ /** Which policy produced the ask: the write mode, the folder floor, or both. */
886
+ export type WriteGateSource = "write" | "folder" | "mixed";
887
+
860
888
  /**
861
889
  * Which "always allow" label fits a set of items.
862
890
  *
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.
891
+ * "path"/"paths" — every item writes files; "folder"/"folders" — every
892
+ * path grant comes from the shared folder floor and covers a folder;
893
+ * "command"/"commands" — every item runs a command (or a path-less tool,
894
+ * whose grant is stored in the command list); "mixed" — more than one kind
895
+ * in one turn.
866
896
  */
867
897
  export type WriteGateOptionKind =
868
898
  | "path"
869
899
  | "paths"
900
+ | "folder"
901
+ | "folders"
870
902
  | "command"
871
903
  | "commands"
872
904
  | "mixed";
@@ -917,8 +949,10 @@ export function writeGateItemFromClassification(
917
949
  why: classification.reason,
918
950
  key: writeGateTargetKey(tool, classification, cwd),
919
951
  };
920
- if (grantPaths.length > 0) item.grantPaths = grantPaths;
921
- else if (classification.key) item.grantCommand = classification.key;
952
+ if (grantPaths.length > 0) {
953
+ item.grantPaths = grantPaths;
954
+ item.grantKind = "file";
955
+ } else if (classification.key) item.grantCommand = classification.key;
922
956
  return item;
923
957
  }
924
958
 
@@ -929,7 +963,12 @@ export function writeGateItemsKind(
929
963
  const paths = items.filter((i) => (i.grantPaths?.length ?? 0) > 0);
930
964
  const commands = items.filter((i) => Boolean(i.grantCommand));
931
965
  if (paths.length > 0 && commands.length > 0) return "mixed";
932
- if (paths.length > 0) return paths.length === 1 ? "path" : "paths";
966
+ if (paths.length > 0) {
967
+ if (paths.every((i) => i.grantKind === "folder")) {
968
+ return paths.length === 1 ? "folder" : "folders";
969
+ }
970
+ return paths.length === 1 ? "path" : "paths";
971
+ }
933
972
  if (commands.length > 0) return commands.length === 1 ? "command" : "commands";
934
973
  return "path";
935
974
  }
@@ -966,19 +1005,47 @@ export function writeGateTargetsLabel(
966
1005
  /**
967
1006
  * The dialog title.
968
1007
  *
969
- * One write keeps today's wording verbatim; several writes are listed, so
970
- * the user sees exactly what the answer covers.
1008
+ * The source is the policy that actually produced the ask: a folder-floor
1009
+ * prompt must not claim the write policy asked (that confusion is what made
1010
+ * the in-repo toolchain regression look like a write-policy bug). One write
1011
+ * keeps today's wording verbatim; several writes are listed.
971
1012
  */
972
- export function writeGateTitle(items: readonly WriteGateItem[]): string {
1013
+ export function writeGateTitle(
1014
+ items: readonly WriteGateItem[],
1015
+ source: WriteGateSource = "write",
1016
+ base?: string,
1017
+ ): string {
1018
+ const outside =
1019
+ source === "folder" && base ? `\nOutside: ${base}` : "";
1020
+ const header =
1021
+ source === "folder"
1022
+ ? "Folder policy 'ask': allow this write outside the permitted folders?"
1023
+ : source === "mixed"
1024
+ ? "Permission required: allow this write? (write policy and folder policy are 'ask')"
1025
+ : "Write policy 'ask': allow this write?";
973
1026
  if (items.length <= 1) {
974
1027
  const only = items[0];
975
- const why = only?.why ? ` (${only.why})` : "";
976
- return `Write policy 'ask': allow this write${why}?`;
1028
+ const target = String(only?.target ?? "").trim();
1029
+ const reason = String(only?.why ?? "").trim();
1030
+ return (
1031
+ header +
1032
+ outside +
1033
+ (target ? `\nTarget: ${target}` : "") +
1034
+ (reason && reason !== target ? `\nReason: ${reason}` : "")
1035
+ );
977
1036
  }
978
- return (
979
- `Write policy 'ask': allow these ${items.length} writes? ` +
980
- `(${describeWriteGateItems(items)})`
981
- );
1037
+ const lines = items
1038
+ .slice(0, 5)
1039
+ .map((item) => ` • ${String(item.target ?? "").trim()}`)
1040
+ .filter((line) => line.trim().length > 3);
1041
+ if (items.length > 5) lines.push(` … and ${items.length - 5} more`);
1042
+ const many =
1043
+ source === "folder"
1044
+ ? `Folder policy 'ask': allow these ${items.length} writes outside the permitted folders?`
1045
+ : source === "mixed"
1046
+ ? `Permission required: allow these ${items.length} writes? (write policy and folder policy are 'ask')`
1047
+ : `Write policy 'ask': allow these ${items.length} writes?`;
1048
+ return `${many}${outside}\n${lines.join("\n")}`;
982
1049
  }
983
1050
 
984
1051
  // ── Argument completions (/write-policy) ────────────────────────────