@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/read-policy-tools.ts
CHANGED
|
@@ -181,7 +181,9 @@ export function classifyReadCall(
|
|
|
181
181
|
const result: ReadCallClassification = { tool, targets: [], uncertain: [] };
|
|
182
182
|
|
|
183
183
|
if (tool === "bash" || tool === "powershell") {
|
|
184
|
-
const analysis = analyzeShellPaths(obj.command, cwd
|
|
184
|
+
const analysis = analyzeShellPaths(obj.command, cwd, {
|
|
185
|
+
family: tool === "powershell" ? "powershell" : "posix",
|
|
186
|
+
});
|
|
185
187
|
result.targets = analysis.reads;
|
|
186
188
|
result.uncertain = analysis.uncertain;
|
|
187
189
|
return result;
|
package/read-policy.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
FolderOutsideAction,
|
|
38
38
|
} from "./folder-policy.ts";
|
|
39
39
|
import {
|
|
40
|
+
dialogGrantRootForTarget,
|
|
40
41
|
grantRootForTarget,
|
|
41
42
|
outsideProjectRootForTarget,
|
|
42
43
|
} from "./folder-policy.ts";
|
|
@@ -327,6 +328,8 @@ export type ReadGateDecision = "allow" | "ask" | "block";
|
|
|
327
328
|
export interface ReadGateInput {
|
|
328
329
|
/** A target matches the sensitive deny list. */
|
|
329
330
|
sensitive: boolean;
|
|
331
|
+
/** A target IS an excluded folder (never askable). */
|
|
332
|
+
excluded?: boolean;
|
|
330
333
|
/** A target is outside the folder roots. */
|
|
331
334
|
outside: boolean;
|
|
332
335
|
outsideAction: FolderOutsideAction;
|
|
@@ -339,6 +342,7 @@ export interface ReadGateInput {
|
|
|
339
342
|
|
|
340
343
|
/**
|
|
341
344
|
* sensitive → block (never softened by a grant)
|
|
345
|
+
* excluded → block (never askable)
|
|
342
346
|
* outside + block → block
|
|
343
347
|
* outside + ask → ask unless already granted
|
|
344
348
|
* uncertain + strict → ask
|
|
@@ -346,6 +350,7 @@ export interface ReadGateInput {
|
|
|
346
350
|
*/
|
|
347
351
|
export function decideReadGate(input: ReadGateInput): ReadGateDecision {
|
|
348
352
|
if (input.sensitive) return "block";
|
|
353
|
+
if (input.excluded) return "block";
|
|
349
354
|
if (input.outside) {
|
|
350
355
|
if (input.outsideAction === "block") return "block";
|
|
351
356
|
if (!input.granted) return "ask";
|
|
@@ -390,7 +395,10 @@ export type ReadGateChoice =
|
|
|
390
395
|
| "deny";
|
|
391
396
|
|
|
392
397
|
/** Labels for the ask dialog; the parser and the UI share this table. */
|
|
393
|
-
export function readGateOptions(
|
|
398
|
+
export function readGateOptions(
|
|
399
|
+
kind: ReadGateItemKind,
|
|
400
|
+
opts: { allowAlways?: boolean } = {},
|
|
401
|
+
): readonly string[] {
|
|
394
402
|
const always =
|
|
395
403
|
kind === "folder"
|
|
396
404
|
? "Always allow this folder"
|
|
@@ -401,7 +409,7 @@ export function readGateOptions(kind: ReadGateItemKind): readonly string[] {
|
|
|
401
409
|
: "";
|
|
402
410
|
return [
|
|
403
411
|
"Allow once (this turn)",
|
|
404
|
-
...(always ? [always] : []),
|
|
412
|
+
...(opts.allowAlways !== false && always ? [always] : []),
|
|
405
413
|
"Deny",
|
|
406
414
|
READ_DENY_WITH_INSTRUCTIONS_LABEL,
|
|
407
415
|
];
|
|
@@ -435,7 +443,7 @@ export function sanitizeReadInstruction(raw: unknown): string {
|
|
|
435
443
|
}
|
|
436
444
|
|
|
437
445
|
export interface ReadDenyReasonInput {
|
|
438
|
-
kind: "sensitive" | "outside" | "uncertain" | "blocked";
|
|
446
|
+
kind: "sensitive" | "excluded" | "outside" | "uncertain" | "blocked";
|
|
439
447
|
target: string;
|
|
440
448
|
pattern?: string;
|
|
441
449
|
outsideAction?: FolderOutsideAction;
|
|
@@ -456,6 +464,13 @@ export function buildReadDenyReason(input: ReadDenyReasonInput): string {
|
|
|
456
464
|
". This cannot be overridden by a folder grant. Do not retry; ask the user if they want to add a manual `sensitiveExemptions` entry." +
|
|
457
465
|
guidance
|
|
458
466
|
);
|
|
467
|
+
case "excluded":
|
|
468
|
+
return (
|
|
469
|
+
`Read policy: \`${input.target}\` is excluded by \`workflow.folderPolicy.excludePaths\`` +
|
|
470
|
+
(input.pattern ? ` (${input.pattern})` : "") +
|
|
471
|
+
". The excluded folder itself is off-limits and cannot be approved from a dialog. Do not retry; a specific sub-folder can still be requested (it needs the user's approval), or ask the user to revoke the rule with `/folder-policy exclude revoke`." +
|
|
472
|
+
guidance
|
|
473
|
+
);
|
|
459
474
|
case "blocked":
|
|
460
475
|
return (
|
|
461
476
|
`Read policy: \`${input.target}\` is outside the permitted folders and the folder policy is 'block'. Do not retry. Ask the user to open the project at that folder or change /folder-policy.` +
|
|
@@ -497,15 +512,14 @@ export function readGateItemFromTarget(
|
|
|
497
512
|
tool: string,
|
|
498
513
|
target: FolderTarget,
|
|
499
514
|
why: string,
|
|
515
|
+
cwd: string,
|
|
500
516
|
baseRoot?: string,
|
|
501
517
|
): ReadGateItem {
|
|
502
|
-
const
|
|
503
|
-
(baseRoot && outsideProjectRootForTarget(target, baseRoot)) ||
|
|
504
|
-
grantRootForTarget(target);
|
|
518
|
+
const { root } = dialogGrantRootForTarget(target, baseRoot, cwd);
|
|
505
519
|
return {
|
|
506
520
|
tool,
|
|
507
521
|
target,
|
|
508
|
-
grantRoot,
|
|
522
|
+
grantRoot: root,
|
|
509
523
|
why,
|
|
510
524
|
key: readGateTargetKey(target),
|
|
511
525
|
};
|
|
@@ -565,12 +579,16 @@ export function readGateDetail(
|
|
|
565
579
|
export function readGateTitle(
|
|
566
580
|
items: readonly ReadGateItem[],
|
|
567
581
|
base?: string,
|
|
582
|
+
opts: { grantBlockedReason?: string } = {},
|
|
568
583
|
): string {
|
|
569
584
|
const detail = readGateDetail(items);
|
|
570
585
|
return (
|
|
571
586
|
`Read policy: allow ${describeReadGateItems(items)}?` +
|
|
572
587
|
(base ? `\nOutside: ${base}` : "") +
|
|
573
|
-
(detail ? `\n${detail}` : "")
|
|
588
|
+
(detail ? `\n${detail}` : "") +
|
|
589
|
+
(opts.grantBlockedReason
|
|
590
|
+
? `\nGrant not available: ${opts.grantBlockedReason}`
|
|
591
|
+
: "")
|
|
574
592
|
);
|
|
575
593
|
}
|
|
576
594
|
|
package/review-runtime.ts
CHANGED
|
@@ -75,6 +75,15 @@ const SETTLE_IN_FLIGHT_FALLBACK_MS = 60_000;
|
|
|
75
75
|
export interface ReviewRuntimeDeps {
|
|
76
76
|
cwd: string;
|
|
77
77
|
agentDir: string;
|
|
78
|
+
/**
|
|
79
|
+
* The parent's effective folder policy (sensitive extras, `excludePaths`)
|
|
80
|
+
* and read grants, handed to the reviewer's guarded tools. Optional so
|
|
81
|
+
* existing tests and embedders keep working.
|
|
82
|
+
*/
|
|
83
|
+
folderPolicy?: () => {
|
|
84
|
+
config: import("./folder-policy.ts").FolderPolicyConfig;
|
|
85
|
+
grants: readonly string[];
|
|
86
|
+
};
|
|
78
87
|
config: () => ReviewModeConfig | undefined;
|
|
79
88
|
/** Resolve the reviewer role's model (falls back to the planner when unset). */
|
|
80
89
|
reviewerModel: () =>
|
|
@@ -822,6 +831,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
822
831
|
const built = createReviewTools(sdk, {
|
|
823
832
|
cwd: deps.cwd,
|
|
824
833
|
log: deps.log,
|
|
834
|
+
folderPolicy: deps.folderPolicy,
|
|
825
835
|
explore: {
|
|
826
836
|
// Step 8: Planner recon rides along in the prompt — when it is
|
|
827
837
|
// present the reviewer verifies instead of re-exploring.
|
package/review-tools.ts
CHANGED
|
@@ -58,6 +58,11 @@ export interface ReviewBashDeps {
|
|
|
58
58
|
cwd: string;
|
|
59
59
|
/** Surface refusals and anomalies to the parent session. */
|
|
60
60
|
log: (message: string, level?: "info" | "warning" | "error") => void;
|
|
61
|
+
/** The parent's folder policy + read grants (see ReviewToolDeps). */
|
|
62
|
+
folderPolicy?: () => {
|
|
63
|
+
config: FolderPolicyConfig;
|
|
64
|
+
grants: readonly string[];
|
|
65
|
+
};
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/** Injectable shell-tool builder so this module is testable without the SDK. */
|
|
@@ -155,18 +160,24 @@ export function createReviewBashTool(
|
|
|
155
160
|
};
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
const guard = reviewerReadGuard(deps.cwd);
|
|
163
|
+
const guard = reviewerReadGuard(deps.cwd, deps.folderPolicy?.());
|
|
159
164
|
const readPaths = analyzeShellPaths(command, deps.cwd).reads;
|
|
160
165
|
const readEval = evaluateFolderTargets(readPaths, {
|
|
161
166
|
config: guard.config,
|
|
162
|
-
grants:
|
|
167
|
+
grants: guard.grants,
|
|
163
168
|
cwd: deps.cwd,
|
|
164
169
|
lockedRoots: [guard.root],
|
|
170
|
+
explicitGrants: guard.grants,
|
|
165
171
|
action: "read",
|
|
166
172
|
});
|
|
167
|
-
if (
|
|
173
|
+
if (
|
|
174
|
+
readEval.sensitive.length > 0 ||
|
|
175
|
+
readEval.excluded.length > 0 ||
|
|
176
|
+
readEval.outside.length > 0
|
|
177
|
+
) {
|
|
168
178
|
const target =
|
|
169
179
|
readEval.sensitive[0]?.path ??
|
|
180
|
+
readEval.excluded[0]?.path ??
|
|
170
181
|
readEval.outside[0]?.path ??
|
|
171
182
|
"the requested path";
|
|
172
183
|
deps.log(
|
|
@@ -192,7 +203,14 @@ export function createReviewBashTool(
|
|
|
192
203
|
onUpdate,
|
|
193
204
|
ctx,
|
|
194
205
|
)) as ReviewToolTextResult;
|
|
195
|
-
return redactReviewResult(
|
|
206
|
+
return redactReviewResult(
|
|
207
|
+
result,
|
|
208
|
+
"bash",
|
|
209
|
+
rawParams,
|
|
210
|
+
deps.cwd,
|
|
211
|
+
guard.config,
|
|
212
|
+
{ base: [guard.root] },
|
|
213
|
+
);
|
|
196
214
|
} catch (e: unknown) {
|
|
197
215
|
return {
|
|
198
216
|
content: [
|
|
@@ -380,6 +398,15 @@ export interface ReviewToolDeps {
|
|
|
380
398
|
explore: ReviewExploreDeps;
|
|
381
399
|
submit: ReviewSubmitDeps;
|
|
382
400
|
passDone: ReviewPassDoneDeps;
|
|
401
|
+
/**
|
|
402
|
+
* The parent's effective folder policy (sensitive extras, `excludePaths`)
|
|
403
|
+
* and read grants. The reviewer is a separate session with no ambient
|
|
404
|
+
* extensions, so without this it would ignore the user's deny layer.
|
|
405
|
+
*/
|
|
406
|
+
folderPolicy?: () => {
|
|
407
|
+
config: FolderPolicyConfig;
|
|
408
|
+
grants: readonly string[];
|
|
409
|
+
};
|
|
383
410
|
}
|
|
384
411
|
|
|
385
412
|
// ── Steps 9-11: review_submit_plan ───────────────────────────────────
|
|
@@ -499,6 +526,10 @@ export function createReviewPlanBeginTool(deps: ReviewSubmitDeps): {
|
|
|
499
526
|
),
|
|
500
527
|
}),
|
|
501
528
|
async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
|
|
529
|
+
// SAFETY: Pi calls every tool `execute(toolCallId, params, …)`,
|
|
530
|
+
// so index 1 is the model-supplied params object. This tool's
|
|
531
|
+
// schema declares `totalChunks`, which is narrowed below before
|
|
532
|
+
// use — nothing else is read off the value.
|
|
502
533
|
const [, rawParams] = args as unknown as [
|
|
503
534
|
string,
|
|
504
535
|
{ totalChunks?: unknown },
|
|
@@ -574,6 +605,9 @@ export function createReviewPlanChunkTool(deps: ReviewSubmitDeps): {
|
|
|
574
605
|
}),
|
|
575
606
|
}),
|
|
576
607
|
async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
|
|
608
|
+
// SAFETY: Pi calls every tool `execute(toolCallId, params, …)`,
|
|
609
|
+
// so index 1 is the model-supplied params object. `index` and
|
|
610
|
+
// `content` are narrowed below before use.
|
|
577
611
|
const [, rawParams] = args as unknown as [
|
|
578
612
|
string,
|
|
579
613
|
{ index?: unknown; content?: unknown },
|
|
@@ -1057,16 +1091,22 @@ export function createReviewPassDoneTool(deps: ReviewPassDoneDeps): {
|
|
|
1057
1091
|
interface ReviewerReadGuard {
|
|
1058
1092
|
root: string;
|
|
1059
1093
|
config: FolderPolicyConfig;
|
|
1094
|
+
/** Explicit grants that can re-open an excluded subtree. */
|
|
1095
|
+
grants: readonly string[];
|
|
1060
1096
|
}
|
|
1061
1097
|
|
|
1062
|
-
function reviewerReadGuard(
|
|
1098
|
+
function reviewerReadGuard(
|
|
1099
|
+
cwd: string,
|
|
1100
|
+
parent?: { config: FolderPolicyConfig; grants: readonly string[] },
|
|
1101
|
+
): ReviewerReadGuard {
|
|
1063
1102
|
return {
|
|
1064
1103
|
root: detectRepoRoot(cwd) ?? cwd,
|
|
1065
1104
|
config: {
|
|
1066
|
-
...DEFAULT_FOLDER_POLICY_CONFIG,
|
|
1105
|
+
...(parent?.config ?? DEFAULT_FOLDER_POLICY_CONFIG),
|
|
1067
1106
|
mode: "cwd",
|
|
1068
1107
|
outside: "block",
|
|
1069
1108
|
},
|
|
1109
|
+
grants: parent?.grants ?? [],
|
|
1070
1110
|
};
|
|
1071
1111
|
}
|
|
1072
1112
|
|
|
@@ -1108,13 +1148,17 @@ function guardReviewerTarget(
|
|
|
1108
1148
|
const hit = matchSensitivePath(value, guard.config, cwd);
|
|
1109
1149
|
const evaluation = evaluateFolderTargets([target], {
|
|
1110
1150
|
config: guard.config,
|
|
1111
|
-
grants:
|
|
1151
|
+
grants: guard.grants,
|
|
1112
1152
|
cwd,
|
|
1113
1153
|
lockedRoots: [guard.root],
|
|
1154
|
+
explicitGrants: guard.grants,
|
|
1114
1155
|
action: "read",
|
|
1115
1156
|
});
|
|
1116
1157
|
if (hit.sensitive || evaluation.sensitive.length > 0)
|
|
1117
1158
|
return reviewerRefusal(target);
|
|
1159
|
+
// The excluded folder itself, or a path inside it that no approved grant
|
|
1160
|
+
// re-opens, is refused to the reviewer too.
|
|
1161
|
+
if (evaluation.excluded.length > 0) return reviewerRefusal(target);
|
|
1118
1162
|
if (evaluation.outside.length > 0) return reviewerRefusal(target);
|
|
1119
1163
|
return undefined;
|
|
1120
1164
|
}
|
|
@@ -1125,11 +1169,19 @@ function redactReviewResult(
|
|
|
1125
1169
|
input: Record<string, unknown>,
|
|
1126
1170
|
cwd: string,
|
|
1127
1171
|
config: FolderPolicyConfig,
|
|
1172
|
+
roots?: { base?: readonly string[]; grants?: readonly string[] },
|
|
1128
1173
|
): ReviewToolTextResult {
|
|
1129
1174
|
let redacted = false;
|
|
1130
1175
|
const content = (result.content ?? []).map((block) => {
|
|
1131
1176
|
if (block?.type !== "text") return block;
|
|
1132
|
-
const next = redactSensitiveText(
|
|
1177
|
+
const next = redactSensitiveText(
|
|
1178
|
+
block.text,
|
|
1179
|
+
tool,
|
|
1180
|
+
input,
|
|
1181
|
+
cwd,
|
|
1182
|
+
config,
|
|
1183
|
+
roots,
|
|
1184
|
+
);
|
|
1133
1185
|
if (next.redacted) redacted = true;
|
|
1134
1186
|
return next.redacted ? { ...block, text: next.text } : block;
|
|
1135
1187
|
});
|
|
@@ -1140,10 +1192,11 @@ function redactReviewResult(
|
|
|
1140
1192
|
function createGuardedBuiltinTools(
|
|
1141
1193
|
sdk: PiCodingAgentSdk | undefined,
|
|
1142
1194
|
cwd: string,
|
|
1195
|
+
parent?: { config: FolderPolicyConfig; grants: readonly string[] },
|
|
1143
1196
|
): { tools: unknown[]; unavailable: string[] } {
|
|
1144
1197
|
const tools: unknown[] = [];
|
|
1145
1198
|
const unavailable: string[] = [];
|
|
1146
|
-
const guard = reviewerReadGuard(cwd);
|
|
1199
|
+
const guard = reviewerReadGuard(cwd, parent);
|
|
1147
1200
|
const builders: Array<[string, string, unknown]> = [
|
|
1148
1201
|
["read", "read", (sdk as any)?.createReadToolDefinition],
|
|
1149
1202
|
["grep", "grep", (sdk as any)?.createGrepToolDefinition],
|
|
@@ -1169,7 +1222,9 @@ function createGuardedBuiltinTools(
|
|
|
1169
1222
|
const result = (await definition.execute(
|
|
1170
1223
|
...args,
|
|
1171
1224
|
)) as ReviewToolTextResult;
|
|
1172
|
-
return redactReviewResult(result, tool, input, cwd, guard.config
|
|
1225
|
+
return redactReviewResult(result, tool, input, cwd, guard.config, {
|
|
1226
|
+
base: [guard.root],
|
|
1227
|
+
});
|
|
1173
1228
|
},
|
|
1174
1229
|
});
|
|
1175
1230
|
}
|
|
@@ -1196,7 +1251,11 @@ export function createReviewTools(
|
|
|
1196
1251
|
// The reviewer session has NO ambient extensions, so its built-in
|
|
1197
1252
|
// read/grep/find/ls are otherwise a complete bypass of the read gate.
|
|
1198
1253
|
// Custom tools with those names override the built-ins.
|
|
1199
|
-
const guarded = createGuardedBuiltinTools(
|
|
1254
|
+
const guarded = createGuardedBuiltinTools(
|
|
1255
|
+
sdk,
|
|
1256
|
+
deps.cwd,
|
|
1257
|
+
deps.folderPolicy?.(),
|
|
1258
|
+
);
|
|
1200
1259
|
tools.push(...guarded.tools);
|
|
1201
1260
|
unavailable.push(...guarded.unavailable);
|
|
1202
1261
|
|
package/shell-paths.ts
CHANGED
|
@@ -25,6 +25,14 @@ import {
|
|
|
25
25
|
type FolderTarget,
|
|
26
26
|
normalizePolicyPath,
|
|
27
27
|
} from "./folder-policy.ts";
|
|
28
|
+
import { translateMsysTempPath } from "./utils.ts";
|
|
29
|
+
|
|
30
|
+
export type ShellPathFamily = "posix" | "powershell" | "cmd";
|
|
31
|
+
|
|
32
|
+
export interface ShellPathOptions {
|
|
33
|
+
/** MSYS `/tmp` translation is a POSIX-shell concern (default: "posix"). */
|
|
34
|
+
family?: ShellPathFamily;
|
|
35
|
+
}
|
|
28
36
|
|
|
29
37
|
export type GitCommitHit = "add" | "commit" | "push";
|
|
30
38
|
|
|
@@ -225,6 +233,7 @@ function dedupeTargets(targets: FolderTarget[]): FolderTarget[] {
|
|
|
225
233
|
export function analyzeShellPaths(
|
|
226
234
|
command: unknown,
|
|
227
235
|
cwd: string,
|
|
236
|
+
options: ShellPathOptions = {},
|
|
228
237
|
): ShellPathAnalysis {
|
|
229
238
|
const analysis: ShellPathAnalysis = {
|
|
230
239
|
reads: [],
|
|
@@ -234,13 +243,18 @@ export function analyzeShellPaths(
|
|
|
234
243
|
if (typeof command !== "string" || !command.trim()) return analysis;
|
|
235
244
|
const parsed = analyzeCommand(command);
|
|
236
245
|
let virtualCwd = normalizePolicyPath(cwd, cwd);
|
|
246
|
+
const family = options.family ?? "posix";
|
|
247
|
+
const translate = (value: string): string =>
|
|
248
|
+
family === "posix" ? translateMsysTempPath(value, { cwd }) : value;
|
|
249
|
+
const normalizeShellPath = (value: string, base = virtualCwd): string =>
|
|
250
|
+
normalizePolicyPath(translate(value), base);
|
|
237
251
|
|
|
238
252
|
// Best-effort command substitution: `echo $(cat ../x)` / backticks. Only
|
|
239
253
|
// simple, non-nested bodies are parsed; the rest stays uncertain.
|
|
240
254
|
for (const match of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)) {
|
|
241
255
|
const inner = (match[1] ?? match[2] ?? "").trim();
|
|
242
256
|
if (!inner) continue;
|
|
243
|
-
const nested = analyzeShellPaths(inner, virtualCwd);
|
|
257
|
+
const nested = analyzeShellPaths(inner, virtualCwd, { family });
|
|
244
258
|
for (const target of nested.reads) analysis.reads.push(target);
|
|
245
259
|
for (const target of nested.writes) analysis.writes.push(target);
|
|
246
260
|
for (const reason of nested.uncertain) analysis.uncertain.push(reason);
|
|
@@ -248,7 +262,7 @@ export function analyzeShellPaths(
|
|
|
248
262
|
}
|
|
249
263
|
|
|
250
264
|
const addRead = (value: string, kind: FolderTarget["kind"] = "file") => {
|
|
251
|
-
const normalized =
|
|
265
|
+
const normalized = normalizeShellPath(value);
|
|
252
266
|
if (!normalized || normalized === "-") return;
|
|
253
267
|
if (/^\d+$/.test(normalized)) return;
|
|
254
268
|
if (normalized.endsWith("/dev/null") || normalized.endsWith("/dev/stdin"))
|
|
@@ -258,7 +272,7 @@ export function analyzeShellPaths(
|
|
|
258
272
|
analysis.reads.push({ path: normalized, kind });
|
|
259
273
|
};
|
|
260
274
|
const addWrite = (value: string, kind: FolderTarget["kind"] = "file") => {
|
|
261
|
-
const normalized =
|
|
275
|
+
const normalized = normalizeShellPath(value);
|
|
262
276
|
if (!normalized || normalized === "-") return;
|
|
263
277
|
if (normalized.endsWith("/dev/null")) return;
|
|
264
278
|
analysis.writes.push({ path: normalized, kind });
|
|
@@ -269,6 +283,8 @@ export function analyzeShellPaths(
|
|
|
269
283
|
analysis,
|
|
270
284
|
addRead,
|
|
271
285
|
addWrite,
|
|
286
|
+
family,
|
|
287
|
+
normalizePath: (value: string) => normalizeShellPath(value),
|
|
272
288
|
get cwd() {
|
|
273
289
|
return virtualCwd;
|
|
274
290
|
},
|
|
@@ -286,6 +302,9 @@ export function analyzeShellPaths(
|
|
|
286
302
|
interface SegmentContext {
|
|
287
303
|
analysis: ShellPathAnalysis;
|
|
288
304
|
cwd: string;
|
|
305
|
+
family: ShellPathFamily;
|
|
306
|
+
/** Translate + normalize one shell path against the current cwd. */
|
|
307
|
+
normalizePath: (value: string) => string;
|
|
289
308
|
addRead: (value: string, kind?: FolderTarget["kind"]) => void;
|
|
290
309
|
addWrite: (value: string, kind?: FolderTarget["kind"]) => void;
|
|
291
310
|
}
|
|
@@ -317,7 +336,7 @@ function analyzeSegment(segment: SegmentAnalysis, ctx: SegmentContext): void {
|
|
|
317
336
|
ctx.analysis.uncertain.push(`cd ${target}`);
|
|
318
337
|
return;
|
|
319
338
|
}
|
|
320
|
-
const next =
|
|
339
|
+
const next = ctx.normalizePath(target);
|
|
321
340
|
if (next) ctx.cwd = next;
|
|
322
341
|
return;
|
|
323
342
|
}
|
|
@@ -759,7 +778,7 @@ function analyzeShell(
|
|
|
759
778
|
ctx.analysis.uncertain.push(`${command} ${word} without a command`);
|
|
760
779
|
return;
|
|
761
780
|
}
|
|
762
|
-
mergeAnalysis(ctx, analyzeShellPaths(inner, ctx.cwd));
|
|
781
|
+
mergeAnalysis(ctx, analyzeShellPaths(inner, ctx.cwd, { family: ctx.family }));
|
|
763
782
|
return;
|
|
764
783
|
}
|
|
765
784
|
if (isFlag(word)) continue;
|
|
@@ -827,7 +846,7 @@ function analyzeGit(words: string[], ctx: SegmentContext): void {
|
|
|
827
846
|
const subcommand = (command[index] ?? "").toLowerCase();
|
|
828
847
|
const args = command.slice(index + 1);
|
|
829
848
|
const repoRoot = rootFlag
|
|
830
|
-
?
|
|
849
|
+
? ctx.normalizePath(rootFlag)
|
|
831
850
|
: normalizePolicyPath(ctx.cwd, ctx.cwd);
|
|
832
851
|
if (rootFlag) {
|
|
833
852
|
// Git metadata and worktree are both read; the commit policy uses repoRoot.
|
|
@@ -884,7 +903,9 @@ function analyzeGit(words: string[], ctx: SegmentContext): void {
|
|
|
884
903
|
ctx.analysis.git = {
|
|
885
904
|
hit: subcommand,
|
|
886
905
|
repoRoot,
|
|
887
|
-
explicitPaths: explicitPaths.map((p) =>
|
|
906
|
+
explicitPaths: explicitPaths.map((p) =>
|
|
907
|
+
normalizePolicyPath(ctx.normalizePath(p), repoRoot),
|
|
908
|
+
),
|
|
888
909
|
allPaths,
|
|
889
910
|
amend,
|
|
890
911
|
usesCwd,
|
|
@@ -953,7 +974,7 @@ function addGitRead(
|
|
|
953
974
|
value: string,
|
|
954
975
|
repoRoot: string,
|
|
955
976
|
): void {
|
|
956
|
-
const normalized = normalizePolicyPath(value, repoRoot);
|
|
977
|
+
const normalized = normalizePolicyPath(ctx.normalizePath(value), repoRoot);
|
|
957
978
|
if (normalized) ctx.analysis.reads.push({ path: normalized, kind: "file" });
|
|
958
979
|
}
|
|
959
980
|
|
package/utils.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
|
|
10
11
|
import { isCommandAllowedForRole } from "./permissions.ts";
|
|
@@ -74,6 +75,103 @@ export function translateForeignPath(value: unknown): {
|
|
|
74
75
|
return { path: raw };
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/** Options for the Git-Bash `/tmp` translator (injectable for tests). */
|
|
79
|
+
export interface MsysPathOptions {
|
|
80
|
+
env?: NodeJS.ProcessEnv;
|
|
81
|
+
platform?: NodeJS.Platform;
|
|
82
|
+
exists?: (value: string) => boolean;
|
|
83
|
+
/** Only translate when the session cwd is a native Windows path. */
|
|
84
|
+
cwd?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A native Windows absolute path (drive-absolute or UNC). */
|
|
88
|
+
export function isNativeWindowsPath(value: string): boolean {
|
|
89
|
+
return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let msysTempCache: { key: string; root: string | undefined } | undefined;
|
|
93
|
+
|
|
94
|
+
function msysTempCacheKey(env: NodeJS.ProcessEnv): string {
|
|
95
|
+
return [
|
|
96
|
+
env.MSYSTEM ?? "",
|
|
97
|
+
env.SHELL ?? "",
|
|
98
|
+
env.ProgramFiles ?? "",
|
|
99
|
+
env.PI_MSYS_TMP ?? "",
|
|
100
|
+
env.TMP ?? "",
|
|
101
|
+
env.TEMP ?? "",
|
|
102
|
+
env.TMPDIR ?? "",
|
|
103
|
+
env.LOCALAPPDATA ?? "",
|
|
104
|
+
env.USERPROFILE ?? "",
|
|
105
|
+
].join("\0");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The real Windows directory behind Git-Bash's `/tmp`.
|
|
110
|
+
*
|
|
111
|
+
* Only meaningful for MSYS/Git-Bash (Pi's default Windows shell): Git for
|
|
112
|
+
* Windows is detected by `%ProgramFiles%\Git\bin\bash.exe`, `MSYSTEM`, or a
|
|
113
|
+
* bash `SHELL`. Candidates are tried in order and the first existing one
|
|
114
|
+
* wins. Injected `env`/`exists` options bypass the cache (tests); no
|
|
115
|
+
* `cygpath` subprocess.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveMsysTempRoot(
|
|
118
|
+
opts: MsysPathOptions = {},
|
|
119
|
+
): string | undefined {
|
|
120
|
+
const platform = opts.platform ?? process.platform;
|
|
121
|
+
if (platform !== "win32") return undefined;
|
|
122
|
+
const env = opts.env ?? process.env;
|
|
123
|
+
const exists = opts.exists ?? fs.existsSync;
|
|
124
|
+
const injected =
|
|
125
|
+
opts.env !== undefined ||
|
|
126
|
+
opts.exists !== undefined ||
|
|
127
|
+
opts.platform !== undefined;
|
|
128
|
+
const key = msysTempCacheKey(env);
|
|
129
|
+
if (!injected && msysTempCache?.key === key) return msysTempCache.root;
|
|
130
|
+
const enabled =
|
|
131
|
+
Boolean(env.PI_MSYS_TMP) ||
|
|
132
|
+
Boolean(env.MSYSTEM) ||
|
|
133
|
+
/bash(?:\.exe)?$/i.test(env.SHELL ?? "") ||
|
|
134
|
+
Boolean(
|
|
135
|
+
env.ProgramFiles &&
|
|
136
|
+
exists(path.join(env.ProgramFiles, "Git", "bin", "bash.exe")),
|
|
137
|
+
);
|
|
138
|
+
if (!enabled) {
|
|
139
|
+
if (!injected) msysTempCache = { key, root: undefined };
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const candidates: string[] = [];
|
|
143
|
+
if (env.PI_MSYS_TMP && isNativeWindowsPath(env.PI_MSYS_TMP))
|
|
144
|
+
candidates.push(env.PI_MSYS_TMP);
|
|
145
|
+
for (const value of [env.TMP, env.TEMP, env.TMPDIR]) {
|
|
146
|
+
if (value && isNativeWindowsPath(value)) candidates.push(value);
|
|
147
|
+
}
|
|
148
|
+
if (env.LOCALAPPDATA)
|
|
149
|
+
candidates.push(path.join(env.LOCALAPPDATA, "Temp"));
|
|
150
|
+
if (env.USERPROFILE)
|
|
151
|
+
candidates.push(path.join(env.USERPROFILE, "AppData", "Local", "Temp"));
|
|
152
|
+
const root = candidates.find((candidate) => exists(candidate));
|
|
153
|
+
if (!injected) msysTempCache = { key, root };
|
|
154
|
+
return root;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Translate the literal `/tmp` prefix (and only that) to the real Windows
|
|
159
|
+
* temp directory for a POSIX shell. Direct tool paths keep Node semantics
|
|
160
|
+
* (`/tmp` really is `C:/tmp` there), so callers opt in.
|
|
161
|
+
*/
|
|
162
|
+
export function translateMsysTempPath(
|
|
163
|
+
value: string,
|
|
164
|
+
opts: MsysPathOptions = {},
|
|
165
|
+
): string {
|
|
166
|
+
if (!value) return value;
|
|
167
|
+
const platform = opts.platform ?? process.platform;
|
|
168
|
+
if (platform !== "win32") return value;
|
|
169
|
+
if (!/^\/tmp(?=\/|$)/.test(value)) return value;
|
|
170
|
+
if (opts.cwd && !isNativeWindowsPath(opts.cwd)) return value;
|
|
171
|
+
const root = resolveMsysTempRoot(opts);
|
|
172
|
+
return root ? `${root}${value.slice(4)}` : value;
|
|
173
|
+
}
|
|
174
|
+
|
|
77
175
|
/**
|
|
78
176
|
* Backward-compatible alias for Plan Mode's command policy (read-only plus
|
|
79
177
|
* test/lint/typecheck). New code should call
|