@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/README.md +113 -15
- package/commit-policy.ts +82 -10
- package/edit-policy.ts +121 -45
- package/folder-policy.ts +2165 -0
- package/index.ts +2493 -270
- package/package.json +9 -2
- package/read-policy-redact.ts +359 -0
- package/read-policy-tools.ts +312 -0
- package/read-policy.ts +672 -0
- package/review-handoff.ts +3 -2
- package/review-recovery.ts +4 -2
- package/review-runtime.ts +152 -52
- package/review-tools.ts +228 -1
- package/review.ts +209 -31
- package/shell-paths.ts +1066 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +166 -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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; "
|
|
864
|
-
*
|
|
865
|
-
* command
|
|
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)
|
|
921
|
-
|
|
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)
|
|
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
|
-
*
|
|
970
|
-
* the
|
|
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(
|
|
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
|
|
976
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
`
|
|
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) ────────────────────────────
|