@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/README.md +104 -20
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +2868 -496
- package/package.json +16 -6
- package/provider-retarget.ts +258 -0
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +126 -0
- package/review-recovery.ts +67 -0
- package/review-runtime.ts +454 -94
- package/review-tools.ts +472 -7
- package/review.ts +451 -33
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
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 {
|
|
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(
|
|
198
|
-
project: path.join(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; "
|
|
864
|
-
*
|
|
865
|
-
* command
|
|
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)
|
|
921
|
-
|
|
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)
|
|
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
|
-
*
|
|
970
|
-
* the
|
|
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(
|
|
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
|
|
976
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
`
|
|
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) ────────────────────────────
|