@dieulc/workflow 0.11.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 +83 -7
- package/commit-policy.ts +8 -0
- package/edit-policy.ts +12 -3
- package/folder-policy.ts +2165 -1159
- package/index.ts +644 -121
- package/package.json +2 -2
- package/read-policy-redact.ts +359 -319
- package/read-policy-tools.ts +3 -1
- package/read-policy.ts +26 -8
- package/review-runtime.ts +10 -0
- package/review-tools.ts +70 -11
- package/shell-paths.ts +29 -8
- package/utils.ts +98 -0
package/index.ts
CHANGED
|
@@ -122,17 +122,28 @@ import {
|
|
|
122
122
|
} from "./commit-policy.ts";
|
|
123
123
|
import {
|
|
124
124
|
baseFolderRoots,
|
|
125
|
+
buildFolderPolicyDeny,
|
|
125
126
|
decideFolderGate,
|
|
126
127
|
describeFolderEvaluation,
|
|
127
128
|
detectRepoRoot,
|
|
129
|
+
dialogGrantRootForTarget,
|
|
130
|
+
broadGrantRootReason,
|
|
128
131
|
isBroadGrantRoot,
|
|
129
|
-
outsideProjectRootForTarget,
|
|
130
132
|
evaluateFolderTargets,
|
|
133
|
+
evaluateBaseCoverage,
|
|
131
134
|
matchSensitivePath,
|
|
132
135
|
readFolderPolicyConfig,
|
|
133
136
|
writeFolderPolicyConfig,
|
|
137
|
+
revokeFolderPolicyListEntry,
|
|
134
138
|
folderPolicyArgumentCompletions,
|
|
135
139
|
folderPolicyConfigPaths,
|
|
140
|
+
EXCLUDE_USAGE,
|
|
141
|
+
grantRootForTarget,
|
|
142
|
+
isBroadExcludePattern,
|
|
143
|
+
isWorkflowSettingsPath,
|
|
144
|
+
normalizeExcludePattern,
|
|
145
|
+
parseExcludeArgs,
|
|
146
|
+
splitPolicyArgs,
|
|
136
147
|
DEFAULT_SENSITIVE_PATTERNS,
|
|
137
148
|
type FolderPolicyConfig,
|
|
138
149
|
type FolderTarget,
|
|
@@ -470,6 +481,7 @@ async function runSingleAgent(
|
|
|
470
481
|
agent: AgentConfig,
|
|
471
482
|
task: string,
|
|
472
483
|
signal?: AbortSignal,
|
|
484
|
+
folderEnv?: Record<string, string>,
|
|
473
485
|
): Promise<{ messages: any[]; stderr: string; exitCode: number; usage: any }> {
|
|
474
486
|
const tmpDir = await fs.promises.mkdtemp(
|
|
475
487
|
path.join(os.tmpdir(), "pi-subagent-"),
|
|
@@ -513,11 +525,13 @@ async function runSingleAgent(
|
|
|
513
525
|
// project's settings.
|
|
514
526
|
env: {
|
|
515
527
|
...subagentEnv(process.env, agent.role),
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
528
|
+
...(folderEnv ?? {
|
|
529
|
+
PI_FOLDER_POLICY_LOCKED: "1",
|
|
530
|
+
PI_FOLDER_POLICY_ROOTS: JSON.stringify([
|
|
531
|
+
detectRepoRoot(cwd) ?? cwd,
|
|
532
|
+
]),
|
|
533
|
+
PI_FOLDER_POLICY_SOURCE: "parent",
|
|
534
|
+
}),
|
|
521
535
|
},
|
|
522
536
|
},
|
|
523
537
|
);
|
|
@@ -1859,6 +1873,32 @@ export default function workflowExtension(
|
|
|
1859
1873
|
return currentSessionCtx?.cwd ?? process.cwd();
|
|
1860
1874
|
}
|
|
1861
1875
|
|
|
1876
|
+
/**
|
|
1877
|
+
* Environment for a spawned subagent: the role, the parent-approved roots
|
|
1878
|
+
* (repo root of the task folder plus the task folder itself) and the
|
|
1879
|
+
* parent's deny layer (sensitive extras + `excludePaths`). The child cannot
|
|
1880
|
+
* widen its own scope: it ignores project settings entirely.
|
|
1881
|
+
*/
|
|
1882
|
+
function subagentFolderEnv(cwd: string): Record<string, string> {
|
|
1883
|
+
const parentCwd = (currentSessionCtx as any)?.cwd ?? cwd;
|
|
1884
|
+
const trusted = (currentSessionCtx as any)?.isProjectTrusted?.() === true;
|
|
1885
|
+
let deny = "";
|
|
1886
|
+
try {
|
|
1887
|
+
deny = buildFolderPolicyDeny(
|
|
1888
|
+
readFolderPolicyConfig(parentCwd, getAgentDir(), { trusted }).config,
|
|
1889
|
+
);
|
|
1890
|
+
} catch (_e) {
|
|
1891
|
+
void _e;
|
|
1892
|
+
}
|
|
1893
|
+
const roots = [...new Set([detectRepoRoot(cwd) ?? cwd, cwd])];
|
|
1894
|
+
return {
|
|
1895
|
+
PI_FOLDER_POLICY_LOCKED: "1",
|
|
1896
|
+
PI_FOLDER_POLICY_ROOTS: JSON.stringify(roots),
|
|
1897
|
+
PI_FOLDER_POLICY_SOURCE: "parent",
|
|
1898
|
+
PI_FOLDER_POLICY_DENY: deny,
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1862
1902
|
/**
|
|
1863
1903
|
* Current plan-file text (absolute or cwd-relative), or `undefined` when there
|
|
1864
1904
|
* is no plan path, the file is gone, or it is unreadable. Never throws.
|
|
@@ -2123,7 +2163,13 @@ export default function workflowExtension(
|
|
|
2123
2163
|
if (m) agent.model = `${m.provider}/${m.id}`;
|
|
2124
2164
|
if (m?.thinking) agent.thinking = m.thinking;
|
|
2125
2165
|
|
|
2126
|
-
const res = await runSingleAgent(
|
|
2166
|
+
const res = await runSingleAgent(
|
|
2167
|
+
cwd,
|
|
2168
|
+
agent,
|
|
2169
|
+
task,
|
|
2170
|
+
signal,
|
|
2171
|
+
subagentFolderEnv(cwd),
|
|
2172
|
+
);
|
|
2127
2173
|
const out = getFinalOutput(res.messages);
|
|
2128
2174
|
if (!out && res.exitCode !== 0) {
|
|
2129
2175
|
throw new Error(
|
|
@@ -2143,6 +2189,20 @@ export default function workflowExtension(
|
|
|
2143
2189
|
reviewRuntime = createReviewRuntime({
|
|
2144
2190
|
cwd,
|
|
2145
2191
|
agentDir,
|
|
2192
|
+
// The reviewer is an isolated session: hand it the parent's deny layer
|
|
2193
|
+
// (sensitive extras + excludePaths) and read grants so its guarded
|
|
2194
|
+
// read/grep/find/ls and review_bash cannot see an excluded tree.
|
|
2195
|
+
folderPolicy: () => {
|
|
2196
|
+
const cwdNow = reviewCwd();
|
|
2197
|
+
const trusted =
|
|
2198
|
+
(currentSessionCtx as any)?.isProjectTrusted?.() === true;
|
|
2199
|
+
return {
|
|
2200
|
+
config: readFolderPolicyConfig(cwdNow, getAgentDir(), { trusted })
|
|
2201
|
+
.config,
|
|
2202
|
+
grants: readReadPolicyConfig(cwdNow, getAgentDir(), { trusted }).config
|
|
2203
|
+
.allowedPaths,
|
|
2204
|
+
};
|
|
2205
|
+
},
|
|
2146
2206
|
config: () => reviewConfigNow()?.config,
|
|
2147
2207
|
reviewerModel: () => {
|
|
2148
2208
|
// The round snapshot wins while set (see executeReviewRound): a
|
|
@@ -3395,26 +3455,238 @@ export default function workflowExtension(
|
|
|
3395
3455
|
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3396
3456
|
const agentDir = getAgentDir();
|
|
3397
3457
|
const trusted = (ctx as any).isProjectTrusted?.() === true;
|
|
3398
|
-
const
|
|
3458
|
+
const split = splitPolicyArgs(args);
|
|
3459
|
+
if (split.error) {
|
|
3460
|
+
ctx.ui.notify(`${split.error}\n${EXCLUDE_USAGE}`, "warning");
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
const tokens = split.tokens;
|
|
3399
3464
|
const sub = (tokens[0] || "status").toLowerCase();
|
|
3400
3465
|
const second = (tokens[1] || "").toLowerCase();
|
|
3401
|
-
|
|
3466
|
+
// Legacy bare-token scope for the mode verbs. `exclude` parses its own
|
|
3467
|
+
// scope (flags only) because a standalone `project`/`global` word there
|
|
3468
|
+
// is a path, not a scope.
|
|
3469
|
+
const modeScope: "global" | "project" = tokens.includes("--project")
|
|
3402
3470
|
? "project"
|
|
3403
|
-
: "global"
|
|
3471
|
+
: tokens.includes("--global")
|
|
3472
|
+
? "global"
|
|
3473
|
+
: tokens.includes("project")
|
|
3474
|
+
? "project"
|
|
3475
|
+
: "global";
|
|
3404
3476
|
const resolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
|
|
3477
|
+
const baseRoot = baseFolderRoots(resolved.config, cwd)[0] ?? cwd;
|
|
3478
|
+
// Per capability, with that capability's real grants: a read grant can
|
|
3479
|
+
// rescue reads while writes and commits stay refused at the base.
|
|
3480
|
+
const baseCoverage = (config: typeof resolved.config) =>
|
|
3481
|
+
evaluateBaseCoverage({
|
|
3482
|
+
config,
|
|
3483
|
+
cwd,
|
|
3484
|
+
lockedRoots: resolved.locked?.roots,
|
|
3485
|
+
grantsByCapability: {
|
|
3486
|
+
read: readReadPolicyConfig(cwd, agentDir, { trusted }).config
|
|
3487
|
+
.allowedPaths,
|
|
3488
|
+
write: readWritePolicyConfig(cwd, agentDir, { trusted }).config
|
|
3489
|
+
.allowedPaths,
|
|
3490
|
+
commit: readCommitPolicyConfig(cwd, agentDir, { trusted }).config
|
|
3491
|
+
.allowedPaths,
|
|
3492
|
+
},
|
|
3493
|
+
});
|
|
3405
3494
|
if (sub === "status") {
|
|
3406
3495
|
const paths = folderPolicyConfigPaths(cwd, agentDir);
|
|
3407
3496
|
const lines = [
|
|
3408
3497
|
`Folder policy: ${resolved.config.mode} (${resolved.sources.mode === "default" ? "default" : resolved.sources.mode + " settings"})`,
|
|
3409
3498
|
` outside: ${resolved.config.outside}; grantScope: ${resolved.config.grantScope}; templates: ${resolved.config.sensitiveTemplates}`,
|
|
3410
|
-
` base: ${
|
|
3499
|
+
` base: ${baseRoot}`,
|
|
3411
3500
|
" unverifiable writes: not prompted (use /write-policy ask for strictness)",
|
|
3412
3501
|
` sensitive: ${DEFAULT_SENSITIVE_PATTERNS.length} default, ${resolved.config.sensitivePatterns.length} extra, ${resolved.config.sensitiveExemptions.length} exemption(s)`,
|
|
3502
|
+
` excluded: ${resolved.config.excludePaths.length} rule(s)`,
|
|
3503
|
+
...(resolved.config.excludePaths.length > 0
|
|
3504
|
+
? [
|
|
3505
|
+
...resolved.config.excludePaths.map((p) => ` ${p}`),
|
|
3506
|
+
" (an excluded folder itself is refused; a sub-folder needs an explicit grant)",
|
|
3507
|
+
]
|
|
3508
|
+
: []),
|
|
3413
3509
|
`Global: ${paths.global}`,
|
|
3414
3510
|
`Project: ${paths.project}${resolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
|
|
3415
|
-
"Usage: /folder-policy repo|cwd|parent|any [global|project] · outside ask|block · grant project|global · templates allow|block · sensitive",
|
|
3511
|
+
"Usage: /folder-policy repo|cwd|parent|any [global|project] · outside ask|block · grant project|global · templates allow|block · sensitive · exclude <path>|add <path>|revoke <path>|list [--global|--project] (quote paths with spaces)",
|
|
3416
3512
|
];
|
|
3417
|
-
|
|
3513
|
+
const coverage = baseCoverage(resolved.config);
|
|
3514
|
+
const blocked = coverage.filter(
|
|
3515
|
+
(entry) => entry.excluded.length > 0 || entry.outside.length > 0,
|
|
3516
|
+
);
|
|
3517
|
+
const rescued = coverage.filter((entry) => entry.rescuedBy);
|
|
3518
|
+
if (blocked.length > 0) {
|
|
3519
|
+
const hit = blocked[0]?.excluded[0] ?? blocked[0]?.outside[0];
|
|
3520
|
+
ctx.ui.notify(
|
|
3521
|
+
[
|
|
3522
|
+
...lines,
|
|
3523
|
+
"",
|
|
3524
|
+
`WARNING: the session folder itself is excluded (${hit?.path ?? baseRoot}).`,
|
|
3525
|
+
`Refused at the session folder: ${blocked.map((entry) => entry.capability).join(", ")}.`,
|
|
3526
|
+
...(rescued.length > 0
|
|
3527
|
+
? [
|
|
3528
|
+
`Still covered by a grant: ${rescued
|
|
3529
|
+
.map((entry) => `${entry.capability} (${entry.rescuedBy})`)
|
|
3530
|
+
.join(", ")}.`,
|
|
3531
|
+
]
|
|
3532
|
+
: []),
|
|
3533
|
+
"Grant a sub-folder explicitly, or revoke with:",
|
|
3534
|
+
" /folder-policy exclude revoke <path>",
|
|
3535
|
+
].join("\n"),
|
|
3536
|
+
"error",
|
|
3537
|
+
);
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
ctx.ui.notify(
|
|
3541
|
+
[
|
|
3542
|
+
...lines,
|
|
3543
|
+
...(rescued.length > 0
|
|
3544
|
+
? [
|
|
3545
|
+
` (the session folder is inside an exclusion, but ${rescued
|
|
3546
|
+
.map((entry) => entry.capability)
|
|
3547
|
+
.join(", ")} is covered by a grant)`,
|
|
3548
|
+
]
|
|
3549
|
+
: []),
|
|
3550
|
+
].join("\n"),
|
|
3551
|
+
"info",
|
|
3552
|
+
);
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
if (sub === "exclude") {
|
|
3556
|
+
const parsed = parseExcludeArgs(tokens.slice(1));
|
|
3557
|
+
if (!parsed.ok) {
|
|
3558
|
+
ctx.ui.notify(`${parsed.message}\n${EXCLUDE_USAGE}`, "warning");
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
const settingsPaths = folderPolicyConfigPaths(cwd, agentDir);
|
|
3562
|
+
const activeLines = (config: typeof resolved.config): string[] => [
|
|
3563
|
+
`Active exclusions (${config.excludePaths.length}):`,
|
|
3564
|
+
...(config.excludePaths.length > 0
|
|
3565
|
+
? config.excludePaths.map((rule) => ` ${rule}`)
|
|
3566
|
+
: [" (none)"]),
|
|
3567
|
+
];
|
|
3568
|
+
if (parsed.action === "list") {
|
|
3569
|
+
ctx.ui.notify(
|
|
3570
|
+
[
|
|
3571
|
+
`Excluded paths (${resolved.config.excludePaths.length}):`,
|
|
3572
|
+
...(resolved.config.excludePaths.length > 0
|
|
3573
|
+
? resolved.config.excludePaths.map((rule) => ` ${rule}`)
|
|
3574
|
+
: [" (none)"]),
|
|
3575
|
+
...(resolved.sources.projectIgnored
|
|
3576
|
+
? ["Project settings are ignored: this project is not trusted."]
|
|
3577
|
+
: []),
|
|
3578
|
+
"The excluded path itself is refused (no dialog, no grant). A path below it",
|
|
3579
|
+
"is outside: it can be approved once, or always via a grant inside it.",
|
|
3580
|
+
"Sensitive files stay blocked even inside an approved sub-folder.",
|
|
3581
|
+
`Global: ${settingsPaths.global}`,
|
|
3582
|
+
`Project: ${settingsPaths.project}`,
|
|
3583
|
+
].join("\n"),
|
|
3584
|
+
"info",
|
|
3585
|
+
);
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
const rawPattern = parsed.pattern ?? "";
|
|
3589
|
+
const trimmedInput = rawPattern.trim();
|
|
3590
|
+
if (parsed.action === "revoke") {
|
|
3591
|
+
const result = revokeFolderPolicyListEntry(
|
|
3592
|
+
parsed.scope,
|
|
3593
|
+
cwd,
|
|
3594
|
+
agentDir,
|
|
3595
|
+
"excludePaths",
|
|
3596
|
+
rawPattern,
|
|
3597
|
+
);
|
|
3598
|
+
if (!result.ok) {
|
|
3599
|
+
ctx.ui.notify(
|
|
3600
|
+
`Could not update the exclude list: ${result.error ?? "unknown error"}`,
|
|
3601
|
+
"error",
|
|
3602
|
+
);
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
const after = readFolderPolicyConfig(cwd, agentDir, { trusted });
|
|
3606
|
+
ctx.ui.notify(
|
|
3607
|
+
[
|
|
3608
|
+
result.removed
|
|
3609
|
+
? `Removed (${parsed.scope}): ${trimmedInput}`
|
|
3610
|
+
: `Not in the exclude list (${parsed.scope}): ${trimmedInput}`,
|
|
3611
|
+
`Settings: ${result.path}`,
|
|
3612
|
+
...activeLines(after.config),
|
|
3613
|
+
].join("\n"),
|
|
3614
|
+
result.removed ? "info" : "warning",
|
|
3615
|
+
);
|
|
3616
|
+
updateStatus(ctx as any);
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
const pattern = normalizeExcludePattern(rawPattern);
|
|
3620
|
+
if (!pattern || pattern === "*" || pattern === "**" || pattern === "**/*") {
|
|
3621
|
+
ctx.ui.notify(
|
|
3622
|
+
"Refusing that exclude pattern: it would block every path. Give a path such as C:/ or ~ (quoting paths that contain spaces).",
|
|
3623
|
+
"warning",
|
|
3624
|
+
);
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
const written = writeFolderPolicyConfig(parsed.scope, cwd, agentDir, {
|
|
3628
|
+
excludePaths: [pattern],
|
|
3629
|
+
});
|
|
3630
|
+
if (!written.ok) {
|
|
3631
|
+
ctx.ui.notify(
|
|
3632
|
+
`Could not write the exclude list: ${written.error ?? "unknown error"}`,
|
|
3633
|
+
"error",
|
|
3634
|
+
);
|
|
3635
|
+
return;
|
|
3636
|
+
}
|
|
3637
|
+
const after = readFolderPolicyConfig(cwd, agentDir, { trusted });
|
|
3638
|
+
const coverage = baseCoverage(after.config);
|
|
3639
|
+
const blocked = coverage.filter(
|
|
3640
|
+
(entry) => entry.excluded.length > 0 || entry.outside.length > 0,
|
|
3641
|
+
);
|
|
3642
|
+
const rescued = coverage.filter((entry) => entry.rescuedBy);
|
|
3643
|
+
const notes: string[] = [];
|
|
3644
|
+
// A successful write is never an error. Only a rule that cripples the
|
|
3645
|
+
// session folder (and that no grant rescues) escalates.
|
|
3646
|
+
let level: "info" | "warning" | "error" = "info";
|
|
3647
|
+
if (blocked.length > 0) {
|
|
3648
|
+
notes.push(
|
|
3649
|
+
`WARNING: this also covers the session folder (${baseRoot}).`,
|
|
3650
|
+
`Refused at the session folder: ${blocked.map((entry) => entry.capability).join(", ")}.`,
|
|
3651
|
+
...(rescued.length > 0
|
|
3652
|
+
? [
|
|
3653
|
+
`Still covered by a grant: ${rescued
|
|
3654
|
+
.map((entry) => `${entry.capability} (${entry.rescuedBy})`)
|
|
3655
|
+
.join(", ")}.`,
|
|
3656
|
+
]
|
|
3657
|
+
: []),
|
|
3658
|
+
"Grant a sub-folder explicitly, or revoke with:",
|
|
3659
|
+
` /folder-policy exclude revoke ${pattern}`,
|
|
3660
|
+
);
|
|
3661
|
+
level = "error";
|
|
3662
|
+
} else if (rescued.length > 0) {
|
|
3663
|
+
notes.push(
|
|
3664
|
+
`Note: this also covers the session folder, but ${rescued
|
|
3665
|
+
.map((entry) => entry.capability)
|
|
3666
|
+
.join(", ")} is covered by an explicit grant.`,
|
|
3667
|
+
);
|
|
3668
|
+
level = "warning";
|
|
3669
|
+
} else if (isBroadExcludePattern(pattern)) {
|
|
3670
|
+
notes.push(
|
|
3671
|
+
`Note: ${pattern} is an OS/drive root or your home directory.`,
|
|
3672
|
+
"Nothing inside it is reachable until a sub-folder is approved once or",
|
|
3673
|
+
"granted explicitly; dialogs cannot grant a root or home in one click.",
|
|
3674
|
+
);
|
|
3675
|
+
level = "warning";
|
|
3676
|
+
}
|
|
3677
|
+
ctx.ui.notify(
|
|
3678
|
+
[
|
|
3679
|
+
`Excluded (${parsed.scope}): ${pattern}` +
|
|
3680
|
+
(trimmedInput && trimmedInput !== pattern
|
|
3681
|
+
? ` (normalised from ${trimmedInput})`
|
|
3682
|
+
: ""),
|
|
3683
|
+
`Settings: ${written.path}`,
|
|
3684
|
+
...activeLines(after.config),
|
|
3685
|
+
...notes,
|
|
3686
|
+
].join("\n"),
|
|
3687
|
+
level,
|
|
3688
|
+
);
|
|
3689
|
+
updateStatus(ctx as any);
|
|
3418
3690
|
return;
|
|
3419
3691
|
}
|
|
3420
3692
|
if (sub === "sensitive") {
|
|
@@ -3442,10 +3714,10 @@ export default function workflowExtension(
|
|
|
3442
3714
|
ctx.ui.notify("Usage: /folder-policy repo|cwd|parent|any · outside ask|block · grant project|global · templates allow|block · sensitive", "info");
|
|
3443
3715
|
return;
|
|
3444
3716
|
}
|
|
3445
|
-
const written = writeFolderPolicyConfig(
|
|
3717
|
+
const written = writeFolderPolicyConfig(modeScope, cwd, agentDir, patch as any);
|
|
3446
3718
|
ctx.ui.notify(
|
|
3447
3719
|
written.ok
|
|
3448
|
-
? `Folder policy updated (${
|
|
3720
|
+
? `Folder policy updated (${modeScope}): ${written.path}`
|
|
3449
3721
|
: `Could not write folder policy: ${written.error ?? "unknown error"}`,
|
|
3450
3722
|
written.ok ? "info" : "error",
|
|
3451
3723
|
);
|
|
@@ -8985,6 +9257,7 @@ export default function workflowExtension(
|
|
|
8985
9257
|
ag.role ? ag : { ...ag, role: "explorer" },
|
|
8986
9258
|
job.task,
|
|
8987
9259
|
signal,
|
|
9260
|
+
subagentFolderEnv(job.cwd ?? cwd),
|
|
8988
9261
|
);
|
|
8989
9262
|
if (signal?.aborted) {
|
|
8990
9263
|
results[idx] = {
|
|
@@ -10263,21 +10536,25 @@ export default function workflowExtension(
|
|
|
10263
10536
|
: { root: candidate, uncertain: true };
|
|
10264
10537
|
}
|
|
10265
10538
|
|
|
10266
|
-
|
|
10539
|
+
interface CommitPathScan {
|
|
10540
|
+
/** Explicit operands first, then index/working-tree paths. */
|
|
10541
|
+
paths: string[];
|
|
10542
|
+
failed?: string;
|
|
10543
|
+
}
|
|
10544
|
+
|
|
10545
|
+
/**
|
|
10546
|
+
* One git scan per `git add|commit`, shared by the sensitive and the
|
|
10547
|
+
* exclusion checks (they must not disagree about which paths are involved,
|
|
10548
|
+
* and running git twice doubled the timeout exposure).
|
|
10549
|
+
*/
|
|
10550
|
+
function scanCommitPaths(
|
|
10267
10551
|
scope: NonNullable<ReturnType<typeof analyzeShellPaths>["git"]>,
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
const
|
|
10271
|
-
for (const
|
|
10272
|
-
const hit = matchSensitivePath(p, folderConfig, scope.repoRoot);
|
|
10273
|
-
if (hit.sensitive)
|
|
10274
|
-
return { path: p, pattern: hit.pattern ?? "sensitive" };
|
|
10275
|
-
}
|
|
10276
|
-
return undefined;
|
|
10552
|
+
): CommitPathScan {
|
|
10553
|
+
const paths: string[] = [];
|
|
10554
|
+
const push = (values: readonly string[]) => {
|
|
10555
|
+
for (const value of values) if (!paths.includes(value)) paths.push(value);
|
|
10277
10556
|
};
|
|
10278
|
-
|
|
10279
|
-
if (explicit) return explicit;
|
|
10280
|
-
|
|
10557
|
+
push(scope.explicitPaths);
|
|
10281
10558
|
if (scope.hit === "add" && scope.allPaths) {
|
|
10282
10559
|
const status = runGitZ(scope.repoRoot, [
|
|
10283
10560
|
"status",
|
|
@@ -10286,9 +10563,8 @@ export default function workflowExtension(
|
|
|
10286
10563
|
"--untracked-files=all",
|
|
10287
10564
|
]);
|
|
10288
10565
|
if (!status.ok)
|
|
10289
|
-
return { failed: status.error ?? "git status failed" };
|
|
10290
|
-
|
|
10291
|
-
if (hit) return hit;
|
|
10566
|
+
return { paths, failed: status.error ?? "git status failed" };
|
|
10567
|
+
push(status.paths);
|
|
10292
10568
|
}
|
|
10293
10569
|
if (scope.hit === "commit") {
|
|
10294
10570
|
const staged = runGitZ(scope.repoRoot, [
|
|
@@ -10298,9 +10574,8 @@ export default function workflowExtension(
|
|
|
10298
10574
|
"-z",
|
|
10299
10575
|
]);
|
|
10300
10576
|
if (!staged.ok)
|
|
10301
|
-
return { failed: staged.error ?? "git diff --cached failed" };
|
|
10302
|
-
|
|
10303
|
-
if (hit) return hit;
|
|
10577
|
+
return { paths, failed: staged.error ?? "git diff --cached failed" };
|
|
10578
|
+
push(staged.paths);
|
|
10304
10579
|
if (scope.allPaths) {
|
|
10305
10580
|
const status = runGitZ(scope.repoRoot, [
|
|
10306
10581
|
"status",
|
|
@@ -10309,11 +10584,24 @@ export default function workflowExtension(
|
|
|
10309
10584
|
"--untracked-files=all",
|
|
10310
10585
|
]);
|
|
10311
10586
|
if (!status.ok)
|
|
10312
|
-
return { failed: status.error ?? "git status failed" };
|
|
10313
|
-
|
|
10314
|
-
if (modified) return modified;
|
|
10587
|
+
return { paths, failed: status.error ?? "git status failed" };
|
|
10588
|
+
push(status.paths);
|
|
10315
10589
|
}
|
|
10316
10590
|
}
|
|
10591
|
+
return { paths };
|
|
10592
|
+
}
|
|
10593
|
+
|
|
10594
|
+
/** First sensitive path in a scan, in scan order. */
|
|
10595
|
+
function commitSensitiveHit(
|
|
10596
|
+
scan: CommitPathScan,
|
|
10597
|
+
scope: NonNullable<ReturnType<typeof analyzeShellPaths>["git"]>,
|
|
10598
|
+
folderConfig: FolderPolicyConfig,
|
|
10599
|
+
): { path: string; pattern: string } | undefined {
|
|
10600
|
+
for (const p of scan.paths) {
|
|
10601
|
+
const hit = matchSensitivePath(p, folderConfig, scope.repoRoot);
|
|
10602
|
+
if (hit.sensitive)
|
|
10603
|
+
return { path: p, pattern: hit.pattern ?? "sensitive" };
|
|
10604
|
+
}
|
|
10317
10605
|
return undefined;
|
|
10318
10606
|
}
|
|
10319
10607
|
|
|
@@ -10352,27 +10640,56 @@ export default function workflowExtension(
|
|
|
10352
10640
|
);
|
|
10353
10641
|
if (resolvedRoot.uncertain)
|
|
10354
10642
|
folderEvaluation.uncertain.push(repoRoot);
|
|
10355
|
-
const folderOutside = folderEvaluation.outside.length > 0;
|
|
10356
10643
|
const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
|
|
10357
|
-
const folderDecision = decideFolderGate({
|
|
10358
|
-
sensitive: false,
|
|
10359
|
-
outside: folderEvaluation.outside.length > 0,
|
|
10360
|
-
outsideAction: folderConfig.outside,
|
|
10361
|
-
uncertain: folderEvaluation.uncertain.length > 0,
|
|
10362
|
-
});
|
|
10363
10644
|
// decideFolderGate already turns uncertainty into ask (outside: ask) or
|
|
10364
10645
|
// block (outside: block); there is no "allow" case left to escalate.
|
|
10365
|
-
const
|
|
10366
|
-
|
|
10367
|
-
: undefined;
|
|
10368
|
-
if (sensitiveHit && "failed" in sensitiveHit) {
|
|
10646
|
+
const scan = gitScope ? scanCommitPaths(gitScope) : undefined;
|
|
10647
|
+
if (scan?.failed) {
|
|
10369
10648
|
return {
|
|
10370
10649
|
block: true,
|
|
10371
10650
|
reason:
|
|
10372
|
-
`Commit policy: the sensitive-file check could not run (${
|
|
10651
|
+
`Commit policy: the sensitive-file/exclusion check could not run (${scan.failed}). ` +
|
|
10373
10652
|
"Refusing to commit unverified. Do not retry; ask the user to commit manually or fix git access.",
|
|
10374
10653
|
};
|
|
10375
10654
|
}
|
|
10655
|
+
const sensitiveHit =
|
|
10656
|
+
scan && gitScope ? commitSensitiveHit(scan, gitScope, folderConfig) : undefined;
|
|
10657
|
+
// The exclusion rule is applied to the same scanned paths with the same
|
|
10658
|
+
// rescue semantics as the folder floor. Git paths are repository-relative.
|
|
10659
|
+
const pathEvaluation =
|
|
10660
|
+
scan && scan.paths.length > 0
|
|
10661
|
+
? evaluateFolderTargets(
|
|
10662
|
+
scan.paths.map((p) => ({
|
|
10663
|
+
path: path.isAbsolute(p) ? p : path.join(repoRoot, p),
|
|
10664
|
+
kind: "file" as const,
|
|
10665
|
+
})),
|
|
10666
|
+
{
|
|
10667
|
+
config: folderConfig,
|
|
10668
|
+
grants: policyConfig.allowedPaths,
|
|
10669
|
+
cwd,
|
|
10670
|
+
action: "commit",
|
|
10671
|
+
},
|
|
10672
|
+
)
|
|
10673
|
+
: undefined;
|
|
10674
|
+
const excludedHit =
|
|
10675
|
+
folderEvaluation.excluded[0] ?? pathEvaluation?.excluded[0];
|
|
10676
|
+
// A staged path below an excluded folder is "outside" (the commit does not
|
|
10677
|
+
// name it, so the floor decides ask/block for the whole commit). Only
|
|
10678
|
+
// exclusion-caused outside targets carry `excludeRoot`; an ordinary
|
|
10679
|
+
// outside repository keeps its existing attribution below.
|
|
10680
|
+
const outsidePathTarget = pathEvaluation?.outside.find(
|
|
10681
|
+
(target) => target.excludeRoot !== undefined,
|
|
10682
|
+
);
|
|
10683
|
+
const pathOutside = outsidePathTarget !== undefined;
|
|
10684
|
+
const folderOutside =
|
|
10685
|
+
folderEvaluation.outside.length > 0 || pathOutside;
|
|
10686
|
+
const folderDecision = decideFolderGate({
|
|
10687
|
+
sensitive: false,
|
|
10688
|
+
excluded: folderEvaluation.excluded.length > 0,
|
|
10689
|
+
outside: folderOutside,
|
|
10690
|
+
outsideAction: folderConfig.outside,
|
|
10691
|
+
uncertain: folderEvaluation.uncertain.length > 0,
|
|
10692
|
+
});
|
|
10376
10693
|
const now = Date.now();
|
|
10377
10694
|
const decision = decideCommitGate({
|
|
10378
10695
|
policy,
|
|
@@ -10381,7 +10698,8 @@ export default function workflowExtension(
|
|
|
10381
10698
|
auth: commitAuthorization,
|
|
10382
10699
|
now,
|
|
10383
10700
|
folder: folderDecision,
|
|
10384
|
-
sensitive: Boolean(sensitiveHit
|
|
10701
|
+
sensitive: Boolean(sensitiveHit),
|
|
10702
|
+
excluded: Boolean(excludedHit),
|
|
10385
10703
|
});
|
|
10386
10704
|
if (decision === "allow") {
|
|
10387
10705
|
// Count a granted `git commit` against the authorization, but only when
|
|
@@ -10404,7 +10722,16 @@ export default function workflowExtension(
|
|
|
10404
10722
|
}
|
|
10405
10723
|
|
|
10406
10724
|
if (decision === "block") {
|
|
10407
|
-
if (
|
|
10725
|
+
if (excludedHit) {
|
|
10726
|
+
return {
|
|
10727
|
+
block: true,
|
|
10728
|
+
reason:
|
|
10729
|
+
`Folder policy: \`${excludedHit.path}\` is excluded by \`workflow.folderPolicy.excludePaths\` (${excludedHit.pattern}). ` +
|
|
10730
|
+
"Committing an excluded path is refused even when the commit mode is 'allow'. " +
|
|
10731
|
+
"Do not retry. Remove it from the index, or ask the user to revoke the rule with `/folder-policy exclude revoke`.",
|
|
10732
|
+
};
|
|
10733
|
+
}
|
|
10734
|
+
if (sensitiveHit) {
|
|
10408
10735
|
return {
|
|
10409
10736
|
block: true,
|
|
10410
10737
|
reason:
|
|
@@ -10415,12 +10742,14 @@ export default function workflowExtension(
|
|
|
10415
10742
|
if (folderDecision === "block" && policy !== "never") {
|
|
10416
10743
|
return {
|
|
10417
10744
|
block: true,
|
|
10418
|
-
reason:
|
|
10419
|
-
`Folder policy is 'block' (workflow.folderPolicy):
|
|
10420
|
-
|
|
10421
|
-
|
|
10422
|
-
|
|
10423
|
-
|
|
10745
|
+
reason: pathOutside
|
|
10746
|
+
? `Folder policy is 'block' (workflow.folderPolicy): the staged path \`${outsidePathTarget?.path}\` is inside an excluded folder. ` +
|
|
10747
|
+
"Committing it is refused even when the commit mode is 'allow'. Do not retry; unstage it, or ask the user to revoke the rule with `/folder-policy exclude revoke`."
|
|
10748
|
+
: `Folder policy is 'block' (workflow.folderPolicy): \`git ${hit}\` targets ${repoRoot}, ` +
|
|
10749
|
+
(folderOutside
|
|
10750
|
+
? `which is outside the permitted folders (${baseRoot}). `
|
|
10751
|
+
: "which could not be verified against the permitted folders. ") +
|
|
10752
|
+
"Do not retry. Ask the user to grant that repository folder or change /folder-policy.",
|
|
10424
10753
|
};
|
|
10425
10754
|
}
|
|
10426
10755
|
return {
|
|
@@ -10447,17 +10776,39 @@ export default function workflowExtension(
|
|
|
10447
10776
|
"but no interactive UI is available. Leave the change uncommitted and let the user decide.",
|
|
10448
10777
|
};
|
|
10449
10778
|
}
|
|
10450
|
-
const
|
|
10451
|
-
|
|
10452
|
-
|
|
10453
|
-
|
|
10779
|
+
const repoGrant = dialogGrantRootForTarget(
|
|
10780
|
+
{ path: repoRoot, kind: "dir" },
|
|
10781
|
+
baseRoot,
|
|
10782
|
+
cwd,
|
|
10783
|
+
);
|
|
10784
|
+
// When the ask is caused by a staged path inside an excluded folder, the
|
|
10785
|
+
// grant must sit inside the exclusion too (a repo-root grant is above it).
|
|
10786
|
+
const rescueGrant =
|
|
10787
|
+
pathOutside && outsidePathTarget
|
|
10788
|
+
? grantRootForTarget(outsidePathTarget)
|
|
10789
|
+
: undefined;
|
|
10790
|
+
const persistGrant = rescueGrant ?? repoGrant.root;
|
|
10791
|
+
const persistBroad = broadGrantRootReason(persistGrant, cwd);
|
|
10792
|
+
const commitScopeOk = folderConfig.grantScope === "global" || trusted;
|
|
10793
|
+
const persistable = !persistBroad && commitScopeOk;
|
|
10794
|
+
const commitRemedyScope = commitScopeOk ? folderConfig.grantScope : "global";
|
|
10795
|
+
const commitGrantBlockedReason =
|
|
10796
|
+
folderOutside && !persistable
|
|
10797
|
+
? persistBroad
|
|
10798
|
+
? `${persistBroad}; add it to workflow.commitPolicy.allowedPaths in the ${commitRemedyScope} settings (/commit-policy status shows the path)`
|
|
10799
|
+
: "this project is not trusted; add it to workflow.commitPolicy.allowedPaths in the global settings (/commit-policy status shows the path)"
|
|
10800
|
+
: undefined;
|
|
10454
10801
|
let sel: unknown;
|
|
10455
10802
|
try {
|
|
10456
10803
|
sel = await ui.select(
|
|
10457
10804
|
`${folderOutside ? "Folder" : "Commit"} policy 'ask': allow \`git ${hit}\`?` +
|
|
10458
10805
|
`\nRepository: ${repoRoot}` +
|
|
10459
|
-
(
|
|
10460
|
-
|
|
10806
|
+
(rescueGrant ? `\nExcluded staged path: ${outsidePathTarget?.path}` : "") +
|
|
10807
|
+
(folderOutside ? `\nOutside: ${baseRoot}` : "") +
|
|
10808
|
+
(commitGrantBlockedReason
|
|
10809
|
+
? `\nGrant not available: ${commitGrantBlockedReason}`
|
|
10810
|
+
: ""),
|
|
10811
|
+
folderOutside && persistable
|
|
10461
10812
|
? ["Allow once", "Always allow this repository folder", "Deny"]
|
|
10462
10813
|
: ["Allow once", "Deny"],
|
|
10463
10814
|
);
|
|
@@ -10466,25 +10817,49 @@ export default function workflowExtension(
|
|
|
10466
10817
|
}
|
|
10467
10818
|
const answer = String(sel ?? "");
|
|
10468
10819
|
if (answer.startsWith("Always allow")) {
|
|
10820
|
+
// The dialog omits "Always" when persistence is impossible; a stale or
|
|
10821
|
+
// forced answer still must not turn an approval into a denial.
|
|
10822
|
+
const allowOnceWithWarning = (message: string) => {
|
|
10823
|
+
try {
|
|
10824
|
+
(ctx as any).ui?.notify?.(message, "warning");
|
|
10825
|
+
} catch (_e) {
|
|
10826
|
+
void _e;
|
|
10827
|
+
}
|
|
10828
|
+
return undefined;
|
|
10829
|
+
};
|
|
10830
|
+
if (persistBroad) {
|
|
10831
|
+
return allowOnceWithWarning(
|
|
10832
|
+
`Folder policy: ${persistBroad} cannot be granted from a dialog. ` +
|
|
10833
|
+
"This commit was allowed once, but no grant was saved. " +
|
|
10834
|
+
`Add it to workflow.commitPolicy.allowedPaths in the ${commitRemedyScope} settings (/commit-policy status shows the path).`,
|
|
10835
|
+
);
|
|
10836
|
+
}
|
|
10469
10837
|
if (folderConfig.grantScope === "project" && !trusted) {
|
|
10470
|
-
return
|
|
10471
|
-
|
|
10472
|
-
|
|
10473
|
-
"
|
|
10474
|
-
|
|
10475
|
-
};
|
|
10838
|
+
return allowOnceWithWarning(
|
|
10839
|
+
"Folder policy: this project is not trusted, so a project-scoped grant would be ignored. " +
|
|
10840
|
+
"This commit was allowed once, but no grant was saved. " +
|
|
10841
|
+
"Add it to workflow.commitPolicy.allowedPaths in the global settings, or trust the project.",
|
|
10842
|
+
);
|
|
10476
10843
|
}
|
|
10477
10844
|
const written = writeCommitPolicyConfig(
|
|
10478
10845
|
folderConfig.grantScope,
|
|
10479
10846
|
cwd,
|
|
10480
10847
|
getAgentDir(),
|
|
10481
|
-
{ allowedPaths: [
|
|
10848
|
+
{ allowedPaths: [persistGrant] },
|
|
10482
10849
|
);
|
|
10483
10850
|
if (!written.ok) {
|
|
10484
|
-
return
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10851
|
+
return allowOnceWithWarning(
|
|
10852
|
+
`Folder policy: could not save the commit folder grant (${written.error ?? "unknown error"}). ` +
|
|
10853
|
+
"This commit was allowed once, but no grant was saved.",
|
|
10854
|
+
);
|
|
10855
|
+
}
|
|
10856
|
+
try {
|
|
10857
|
+
(ctx as any).ui?.notify?.(
|
|
10858
|
+
`Commit policy: always allowed ${persistGrant}`,
|
|
10859
|
+
"info",
|
|
10860
|
+
);
|
|
10861
|
+
} catch (_e) {
|
|
10862
|
+
void _e;
|
|
10488
10863
|
}
|
|
10489
10864
|
return undefined;
|
|
10490
10865
|
}
|
|
@@ -10562,7 +10937,9 @@ export default function workflowExtension(
|
|
|
10562
10937
|
}
|
|
10563
10938
|
if (toolName === "bash" || toolName === "powershell") {
|
|
10564
10939
|
const command = String(input?.command ?? "");
|
|
10565
|
-
return analyzeShellPaths(command, cwd
|
|
10940
|
+
return analyzeShellPaths(command, cwd, {
|
|
10941
|
+
family: toolName === "powershell" ? "powershell" : "posix",
|
|
10942
|
+
}).writes;
|
|
10566
10943
|
}
|
|
10567
10944
|
return [];
|
|
10568
10945
|
}
|
|
@@ -10576,22 +10953,24 @@ export default function workflowExtension(
|
|
|
10576
10953
|
const grantEntries = targets
|
|
10577
10954
|
.map((target) => {
|
|
10578
10955
|
const exact = normalizeWritePath(target.path, cwd);
|
|
10579
|
-
|
|
10580
|
-
|
|
10956
|
+
// The outside-project grouping, the exclusion rescue and the
|
|
10957
|
+
// broad-root fallback live in one shared helper so read, write and
|
|
10958
|
+
// commit cannot disagree about what "Always allow" persists.
|
|
10959
|
+
const { root } = dialogGrantRootForTarget(target, baseRoot, cwd);
|
|
10581
10960
|
// A dir/repo target (or a glob's static prefix) is granted as a
|
|
10582
10961
|
// folder; so is a containing folder computed for an outside file. A
|
|
10583
10962
|
// file directly in the common parent is granted as that file alone.
|
|
10584
|
-
const folder =
|
|
10585
|
-
|
|
10586
|
-
(outsideRoot !== undefined && outsideRoot !== exact);
|
|
10587
|
-
return { path, folder };
|
|
10963
|
+
const folder = target.kind !== "file" || root !== exact;
|
|
10964
|
+
return { path: root, folder };
|
|
10588
10965
|
})
|
|
10589
10966
|
.filter((entry) => entry.path.length > 0);
|
|
10590
10967
|
if (grantEntries.length === 0) return item;
|
|
10591
10968
|
const unique = [...new Set(grantEntries.map((entry) => entry.path))].sort();
|
|
10969
|
+
const exclusionReason = targets.find((target) => target.reason)?.reason;
|
|
10592
10970
|
return {
|
|
10593
10971
|
...item,
|
|
10594
10972
|
target: unique.join(", "),
|
|
10973
|
+
why: exclusionReason ?? item.why,
|
|
10595
10974
|
grantPaths: [...new Set([...(item.grantPaths ?? []), ...unique])],
|
|
10596
10975
|
grantKind: grantEntries.every((entry) => entry.folder)
|
|
10597
10976
|
? "folder"
|
|
@@ -10768,6 +11147,22 @@ export default function workflowExtension(
|
|
|
10768
11147
|
classification,
|
|
10769
11148
|
cwd,
|
|
10770
11149
|
);
|
|
11150
|
+
// Workflow settings are user-owned: an agent that could rewrite them
|
|
11151
|
+
// could delete its own exclusions. Applies in every mode, including
|
|
11152
|
+
// Build, and is never softened by a grant. Slash commands write through
|
|
11153
|
+
// fs and are unaffected.
|
|
11154
|
+
const settingsTarget = folderTargets.find((folderTarget) =>
|
|
11155
|
+
isWorkflowSettingsPath(folderTarget.path, cwd, getAgentDir()),
|
|
11156
|
+
);
|
|
11157
|
+
if (settingsTarget) {
|
|
11158
|
+
return {
|
|
11159
|
+
block: true,
|
|
11160
|
+
reason:
|
|
11161
|
+
`Write policy: \`${settingsTarget.path}\` is the workflow settings file, which is user-owned. ` +
|
|
11162
|
+
"The agent may not rewrite permissions or the exclude list, even with a write grant or in Build mode. " +
|
|
11163
|
+
"Do not retry. Ask the user to run the matching slash command (/folder-policy, /read-policy, /write-policy, /commit-policy) or edit the file themselves.",
|
|
11164
|
+
};
|
|
11165
|
+
}
|
|
10771
11166
|
for (const folderTarget of folderTargets) {
|
|
10772
11167
|
const hit = matchSensitivePath(folderTarget.path, folderConfig, cwd);
|
|
10773
11168
|
if (hit.sensitive) {
|
|
@@ -10790,6 +11185,7 @@ export default function workflowExtension(
|
|
|
10790
11185
|
});
|
|
10791
11186
|
const folderDecision = decideFolderGate({
|
|
10792
11187
|
sensitive: false,
|
|
11188
|
+
excluded: folderEvaluation.excluded.length > 0,
|
|
10793
11189
|
outside: folderEvaluation.outside.length > 0,
|
|
10794
11190
|
outsideAction: folderConfig.outside,
|
|
10795
11191
|
// Only a concrete target path that cannot be canonicalised is folder
|
|
@@ -10841,6 +11237,18 @@ export default function workflowExtension(
|
|
|
10841
11237
|
: "";
|
|
10842
11238
|
|
|
10843
11239
|
if (decision === "block") {
|
|
11240
|
+
// An excluded folder (or the excluded root itself) is the most
|
|
11241
|
+
// specific reason: name the rule and the revoke command.
|
|
11242
|
+
if (folderEvaluation.excluded.length > 0) {
|
|
11243
|
+
const hit = folderEvaluation.excluded[0];
|
|
11244
|
+
return {
|
|
11245
|
+
block: true,
|
|
11246
|
+
reason:
|
|
11247
|
+
`Folder policy: \`${hit.path}\` is excluded by \`workflow.folderPolicy.excludePaths\` (${hit.pattern}). ` +
|
|
11248
|
+
"Writing to an excluded folder is refused in every mode, including Build, and cannot be overridden by a write grant. " +
|
|
11249
|
+
`Do not retry. A specific sub-folder can still be requested; ask the user to revoke the rule with \`/folder-policy exclude revoke\`. Target: ${target}`,
|
|
11250
|
+
};
|
|
11251
|
+
}
|
|
10844
11252
|
if (folderDecision === "block" && effectiveWriteMode !== "never") {
|
|
10845
11253
|
return {
|
|
10846
11254
|
block: true,
|
|
@@ -10959,10 +11367,30 @@ export default function workflowExtension(
|
|
|
10959
11367
|
}
|
|
10960
11368
|
|
|
10961
11369
|
// (c) The first gated call of the turn → ask ONCE.
|
|
10962
|
-
const
|
|
11370
|
+
const grantPaths = [
|
|
11371
|
+
...new Set(items.flatMap((item) => item.grantPaths ?? [])),
|
|
11372
|
+
];
|
|
11373
|
+
const broad = grantPaths.find((root) => isBroadGrantRoot(root, cwd));
|
|
11374
|
+
const broadReason = broad ? broadGrantRootReason(broad, cwd) : undefined;
|
|
11375
|
+
const scopeOk = folderConfig.grantScope === "global" || trusted;
|
|
11376
|
+
const persistable = !broad && scopeOk;
|
|
11377
|
+
const remedyScope = scopeOk ? folderConfig.grantScope : "global";
|
|
11378
|
+
const writeGrantBlockedReason = !persistable
|
|
11379
|
+
? broad
|
|
11380
|
+
? `${broadReason ?? "a protected root"}; add it to workflow.writePolicy.allowedPaths in the ${remedyScope} settings (/write-policy status shows the path)`
|
|
11381
|
+
: "this project is not trusted; add it to workflow.writePolicy.allowedPaths in the global settings (/write-policy status shows the path)"
|
|
11382
|
+
: undefined;
|
|
11383
|
+
const options = writeGateOptions(writeGateItemsKind(items), {
|
|
11384
|
+
allowAlways: persistable,
|
|
11385
|
+
});
|
|
10963
11386
|
let sel: unknown;
|
|
10964
11387
|
try {
|
|
10965
|
-
sel = await ui.select(
|
|
11388
|
+
sel = await ui.select(
|
|
11389
|
+
writeGateTitle(items, gateSource, baseRoot, {
|
|
11390
|
+
grantBlockedReason: writeGrantBlockedReason,
|
|
11391
|
+
}),
|
|
11392
|
+
options,
|
|
11393
|
+
);
|
|
10966
11394
|
} catch (_e) {
|
|
10967
11395
|
void _e;
|
|
10968
11396
|
sel = undefined;
|
|
@@ -10984,14 +11412,31 @@ export default function workflowExtension(
|
|
|
10984
11412
|
for (const p of item.grantPaths ?? []) allowedPaths.push(p);
|
|
10985
11413
|
if (item.grantCommand) allowedCommands.push(item.grantCommand);
|
|
10986
11414
|
}
|
|
11415
|
+
// The dialog omits "Always" when persistence is impossible, but a
|
|
11416
|
+
// stale/forced answer must never turn an approval into a denial.
|
|
11417
|
+
const allowOnceWithWarning = (message: string) => {
|
|
11418
|
+
try {
|
|
11419
|
+
(ctx as any).ui?.notify?.(message, "warning");
|
|
11420
|
+
} catch (_e) {
|
|
11421
|
+
void _e;
|
|
11422
|
+
}
|
|
11423
|
+
writeGateTurnAnswer = { choice: "allow-once", instruction: "" };
|
|
11424
|
+
return undefined;
|
|
11425
|
+
};
|
|
11426
|
+
if (broad) {
|
|
11427
|
+
return allowOnceWithWarning(
|
|
11428
|
+
`${gatePolicyLabel}: ${broadReason ?? "a protected root"} cannot be granted from a dialog. ` +
|
|
11429
|
+
"This write was allowed once, but no grant was saved. " +
|
|
11430
|
+
`Add it to workflow.writePolicy.allowedPaths in the ${remedyScope} settings (/write-policy status shows the path).`,
|
|
11431
|
+
);
|
|
11432
|
+
}
|
|
10987
11433
|
const grantScope = folderConfig.grantScope;
|
|
10988
11434
|
if (grantScope === "project" && !trusted) {
|
|
10989
|
-
return
|
|
10990
|
-
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
};
|
|
11435
|
+
return allowOnceWithWarning(
|
|
11436
|
+
`${gatePolicyLabel}: this project is not trusted, so a project-scoped grant would be ignored. ` +
|
|
11437
|
+
"This write was allowed once, but no grant was saved. " +
|
|
11438
|
+
"Add it to workflow.writePolicy.allowedPaths in the global settings, or trust the project.",
|
|
11439
|
+
);
|
|
10995
11440
|
}
|
|
10996
11441
|
const written = writeWritePolicyConfig(grantScope, cwd, getAgentDir(), {
|
|
10997
11442
|
allowedPaths,
|
|
@@ -11009,14 +11454,11 @@ export default function workflowExtension(
|
|
|
11009
11454
|
writeGateTurnAnswer = { choice, instruction: "" };
|
|
11010
11455
|
return undefined;
|
|
11011
11456
|
}
|
|
11012
|
-
|
|
11013
|
-
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11017
|
-
`${gatePolicyLabel} 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
|
|
11018
|
-
`Target: ${target}`,
|
|
11019
|
-
};
|
|
11457
|
+
return allowOnceWithWarning(
|
|
11458
|
+
`${gatePolicyLabel}: could not save the permission grant (${written.error ?? "unknown error"}). ` +
|
|
11459
|
+
"This write was allowed once, but no grant was saved. " +
|
|
11460
|
+
`Add it to workflow.writePolicy.allowedPaths in the ${remedyScope} settings (/write-policy status shows the path).`,
|
|
11461
|
+
);
|
|
11020
11462
|
}
|
|
11021
11463
|
|
|
11022
11464
|
// ── Denial ──
|
|
@@ -11141,7 +11583,8 @@ export default function workflowExtension(
|
|
|
11141
11583
|
const item = readGateItemFromTarget(
|
|
11142
11584
|
name,
|
|
11143
11585
|
target,
|
|
11144
|
-
"outside the folder",
|
|
11586
|
+
target.reason ?? "outside the folder",
|
|
11587
|
+
cwd,
|
|
11145
11588
|
baseRoot,
|
|
11146
11589
|
);
|
|
11147
11590
|
if (seen.has(item.key)) continue;
|
|
@@ -11251,10 +11694,24 @@ export default function workflowExtension(
|
|
|
11251
11694
|
};
|
|
11252
11695
|
}
|
|
11253
11696
|
|
|
11697
|
+
// An excluded folder is refused outright: no dialog, no grant.
|
|
11698
|
+
if (evaluation.excluded.length > 0) {
|
|
11699
|
+
const hit = evaluation.excluded[0];
|
|
11700
|
+
return {
|
|
11701
|
+
block: true,
|
|
11702
|
+
reason: buildReadDenyReason({
|
|
11703
|
+
kind: "excluded",
|
|
11704
|
+
target: hit.path,
|
|
11705
|
+
pattern: hit.pattern,
|
|
11706
|
+
}),
|
|
11707
|
+
};
|
|
11708
|
+
}
|
|
11709
|
+
|
|
11254
11710
|
const outside = evaluation.outside;
|
|
11255
11711
|
const uncertain = classification.uncertain;
|
|
11256
11712
|
let decision = decideReadGate({
|
|
11257
11713
|
sensitive: false,
|
|
11714
|
+
excluded: evaluation.excluded.length > 0,
|
|
11258
11715
|
outside: outside.length > 0,
|
|
11259
11716
|
outsideAction: folderConfig.outside,
|
|
11260
11717
|
granted: false,
|
|
@@ -11346,10 +11803,30 @@ export default function workflowExtension(
|
|
|
11346
11803
|
return undefined;
|
|
11347
11804
|
}
|
|
11348
11805
|
|
|
11349
|
-
const
|
|
11806
|
+
const roots = [
|
|
11807
|
+
...new Set(items.map((item) => item.grantRoot).filter(Boolean)),
|
|
11808
|
+
];
|
|
11809
|
+
const broad = roots.find((root) => isBroadGrantRoot(root, cwd));
|
|
11810
|
+
const broadReason = broad ? broadGrantRootReason(broad, cwd) : undefined;
|
|
11811
|
+
const scopeOk = folderConfig.grantScope === "global" || trusted;
|
|
11812
|
+
const persistable = roots.length > 0 && !broad && scopeOk;
|
|
11813
|
+
const exactTarget = items[0]?.target.path ?? roots[0] ?? "";
|
|
11814
|
+
const remedyScope = scopeOk ? folderConfig.grantScope : "global";
|
|
11815
|
+
const grantBlockedReason =
|
|
11816
|
+
!persistable && roots.length > 0
|
|
11817
|
+
? broad
|
|
11818
|
+
? `${broadReason ?? "a protected root"}; persist the exact target with /read-policy allow ${exactTarget} ${remedyScope}`
|
|
11819
|
+
: `this project is not trusted; persist with /read-policy allow ${exactTarget} global`
|
|
11820
|
+
: undefined;
|
|
11821
|
+
const options = readGateOptions(readGateItemsKind(items), {
|
|
11822
|
+
allowAlways: persistable || roots.length === 0,
|
|
11823
|
+
});
|
|
11350
11824
|
let sel: unknown;
|
|
11351
11825
|
try {
|
|
11352
|
-
sel = await ui.select(
|
|
11826
|
+
sel = await ui.select(
|
|
11827
|
+
readGateTitle(items, baseRoot, { grantBlockedReason }),
|
|
11828
|
+
options,
|
|
11829
|
+
);
|
|
11353
11830
|
} catch {
|
|
11354
11831
|
sel = undefined;
|
|
11355
11832
|
}
|
|
@@ -11361,30 +11838,40 @@ export default function workflowExtension(
|
|
|
11361
11838
|
return undefined;
|
|
11362
11839
|
}
|
|
11363
11840
|
if (choice === "always") {
|
|
11364
|
-
const roots = [
|
|
11365
|
-
...new Set(items.map((item) => item.grantRoot).filter(Boolean)),
|
|
11366
|
-
];
|
|
11367
11841
|
if (roots.length === 0) {
|
|
11368
11842
|
for (const reason of uncertain)
|
|
11369
11843
|
readGateTurnUncertainAllowed.add(reason);
|
|
11370
11844
|
readGateTurnAnswer = { choice: "always", instruction: "" };
|
|
11371
11845
|
return undefined;
|
|
11372
11846
|
}
|
|
11373
|
-
|
|
11847
|
+
// The dialog omits "Always" when persistence is impossible, but a
|
|
11848
|
+
// stale/forced answer must never turn an approval into a denial.
|
|
11849
|
+
const allowOnceWithWarning = (message: string) => {
|
|
11850
|
+
try {
|
|
11851
|
+
(ctx as any).ui?.notify?.(message, "warning");
|
|
11852
|
+
} catch (_e) {
|
|
11853
|
+
void _e;
|
|
11854
|
+
}
|
|
11855
|
+
for (const item of items)
|
|
11856
|
+
if (item.grantRoot) readGateTurnGrantedRoots.add(item.grantRoot);
|
|
11857
|
+
for (const reason of uncertain)
|
|
11858
|
+
readGateTurnUncertainAllowed.add(reason);
|
|
11859
|
+
readGateTurnAnswer = { choice: "allow-once", instruction: "" };
|
|
11860
|
+
return undefined;
|
|
11861
|
+
};
|
|
11374
11862
|
if (broad) {
|
|
11375
|
-
return
|
|
11376
|
-
|
|
11377
|
-
|
|
11378
|
-
`
|
|
11379
|
-
|
|
11863
|
+
return allowOnceWithWarning(
|
|
11864
|
+
`Read policy: ${broadReason ?? "a protected root"} cannot be granted from a dialog. ` +
|
|
11865
|
+
"This read was allowed once, but no grant was saved. " +
|
|
11866
|
+
`Persist it deliberately with /read-policy allow ${exactTarget} ${remedyScope}.`,
|
|
11867
|
+
);
|
|
11380
11868
|
}
|
|
11381
11869
|
if (folderConfig.grantScope === "project" && !trusted) {
|
|
11382
|
-
return
|
|
11383
|
-
|
|
11384
|
-
|
|
11385
|
-
|
|
11386
|
-
|
|
11387
|
-
};
|
|
11870
|
+
return allowOnceWithWarning(
|
|
11871
|
+
"Read policy: this project is not trusted, so a project-scoped grant would be ignored. " +
|
|
11872
|
+
"This read was allowed once, but no grant was saved. " +
|
|
11873
|
+
`Persist it with /read-policy allow ${exactTarget} global, or trust the project.`,
|
|
11874
|
+
);
|
|
11388
11875
|
}
|
|
11389
11876
|
const written = writeReadPolicyConfig(
|
|
11390
11877
|
folderConfig.grantScope,
|
|
@@ -11393,10 +11880,19 @@ export default function workflowExtension(
|
|
|
11393
11880
|
{ allowedPaths: roots },
|
|
11394
11881
|
);
|
|
11395
11882
|
if (!written.ok) {
|
|
11396
|
-
return
|
|
11397
|
-
|
|
11398
|
-
|
|
11399
|
-
|
|
11883
|
+
return allowOnceWithWarning(
|
|
11884
|
+
`Read policy: could not save the permission grant (${written.error ?? "unknown error"}). ` +
|
|
11885
|
+
"This read was allowed once, but no grant was saved. " +
|
|
11886
|
+
`Persist it with /read-policy allow ${exactTarget} ${remedyScope}.`,
|
|
11887
|
+
);
|
|
11888
|
+
}
|
|
11889
|
+
try {
|
|
11890
|
+
(ctx as any).ui?.notify?.(
|
|
11891
|
+
`Read policy: always allowed ${roots.join(", ")}`,
|
|
11892
|
+
"info",
|
|
11893
|
+
);
|
|
11894
|
+
} catch (_e) {
|
|
11895
|
+
void _e;
|
|
11400
11896
|
}
|
|
11401
11897
|
for (const root of roots) readGateTurnGrantedRoots.add(root);
|
|
11402
11898
|
for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
|
|
@@ -11680,6 +12176,14 @@ export default function workflowExtension(
|
|
|
11680
12176
|
action: "commit",
|
|
11681
12177
|
},
|
|
11682
12178
|
);
|
|
12179
|
+
if (folder.excluded.length > 0) {
|
|
12180
|
+
const hit = folder.excluded[0];
|
|
12181
|
+
ctx?.ui?.notify?.(
|
|
12182
|
+
`Per-step commit skipped: ${repoRoot} is excluded by workflow.folderPolicy.excludePaths (${hit.pattern}). Revoke it with /folder-policy exclude revoke or commit manually.`,
|
|
12183
|
+
"warning",
|
|
12184
|
+
);
|
|
12185
|
+
return;
|
|
12186
|
+
}
|
|
11683
12187
|
if (folder.outside.length > 0) {
|
|
11684
12188
|
ctx?.ui?.notify?.(
|
|
11685
12189
|
`Per-step commit skipped: ${repoRoot} is outside the permitted folders (use /folder-policy or grant it first).`,
|
|
@@ -11722,6 +12226,16 @@ export default function workflowExtension(
|
|
|
11722
12226
|
action: "commit",
|
|
11723
12227
|
},
|
|
11724
12228
|
);
|
|
12229
|
+
if (
|
|
12230
|
+
statusFolder.excluded.length > 0
|
|
12231
|
+
) {
|
|
12232
|
+
const hit = statusFolder.excluded[0];
|
|
12233
|
+
ctx?.ui?.notify?.(
|
|
12234
|
+
`Per-step commit skipped: ${hit.path} is excluded by workflow.folderPolicy.excludePaths (${hit.pattern}). Revoke it with /folder-policy exclude revoke or commit manually.`,
|
|
12235
|
+
"warning",
|
|
12236
|
+
);
|
|
12237
|
+
return;
|
|
12238
|
+
}
|
|
11725
12239
|
if (statusFolder.outside.length > 0 || statusFolder.uncertain.length > 0) {
|
|
11726
12240
|
ctx?.ui?.notify?.(
|
|
11727
12241
|
`Per-step commit skipped: staged paths leave the permitted folders or could not be verified. Review the working tree and commit manually.`,
|
|
@@ -11791,10 +12305,19 @@ export default function workflowExtension(
|
|
|
11791
12305
|
pi.on("tool_result", async (event, ctx) => {
|
|
11792
12306
|
try {
|
|
11793
12307
|
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
12308
|
+
const trusted = (ctx as any)?.isProjectTrusted?.() === true;
|
|
11794
12309
|
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
11795
|
-
trusted
|
|
12310
|
+
trusted,
|
|
11796
12311
|
}).config;
|
|
11797
|
-
|
|
12312
|
+
// Roots that can re-open an excluded path: the session floor plus the
|
|
12313
|
+
// read grants (an approved sub-folder must stay readable).
|
|
12314
|
+
const readGrants = readReadPolicyConfig(cwd, getAgentDir(), {
|
|
12315
|
+
trusted,
|
|
12316
|
+
}).config.allowedPaths;
|
|
12317
|
+
return redactToolResult(event as any, folderConfig, cwd, {
|
|
12318
|
+
base: baseFolderRoots(folderConfig, cwd),
|
|
12319
|
+
grants: [...readGrants, ...readGateTurnGrantedRoots],
|
|
12320
|
+
}) as any;
|
|
11798
12321
|
} catch (error: unknown) {
|
|
11799
12322
|
const tool = String((event as any)?.toolName ?? "");
|
|
11800
12323
|
if (
|