@dieulc/workflow 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shell-text.ts ADDED
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Workflow Extension — shell text analysis
3
+ *
4
+ * One quote-aware view of a shell command, shared by every policy check.
5
+ *
6
+ * WHY THIS EXISTS: the command policy used to answer its questions with four
7
+ * different scanners that disagreed about quotes. `splitCommandSegments` was
8
+ * quote-aware, `wordsOf` was quote-aware but not escape-aware, and both
9
+ * `forbiddenSyntaxReason` and the destructive-keyword denylist scanned the
10
+ * RAW text. The result was the wrong answer in both directions: `rg -n
11
+ * "rm -rf" src` was refused in Plan mode although it only searches for a
12
+ * string, and `rg -n 'a > b' docs` was classified as a *file write* although
13
+ * the `>` is inside single quotes.
14
+ *
15
+ * The fix is to ask each question against the view that can answer it. A
16
+ * shell command is three things at once:
17
+ *
18
+ * structural — what the shell will treat as an OPERATOR (`| & ; < > ( )`
19
+ * and a bare `&`). Quoted and backslash-escaped characters
20
+ * are masked, because they are literal text: `rg 'a > b'`
21
+ * writes nothing, `rg 'a' > out` does.
22
+ * expandable — what the shell will EXPAND. Single quotes and escapes are
23
+ * masked; double quotes are kept, because `$(…)`, `${…}` and
24
+ * backticks DO expand inside double quotes.
25
+ * words — what argv the process will receive, after quote removal.
26
+ * Flags must be read from here: `find . "-delete"` really
27
+ * deletes, because quote removal makes it a flag.
28
+ *
29
+ * Both masked views are the same length as the input and use a sentinel for
30
+ * masked characters, so an offset maps back to the original text. That is
31
+ * what lets a refusal name the offending construct without re-scanning.
32
+ *
33
+ * CONTRACT — `ShellSegment.words` is ARGV ONLY. Shell operators (`|`, `;`,
34
+ * `&&`, `||`, a newline, and redirects such as `>`, `>>`, `<`, `<<`, `2>&1`,
35
+ * `&>/dev/null`) are NOT words; redirects are reported separately in
36
+ * `redirects`. So `words[0]` is the command name and `words[1]` is its first
37
+ * argument, exactly what `firstWord` / `wordAt(s, 1)` meant before — and a
38
+ * leading `2>/dev/null` no longer hides the command.
39
+ *
40
+ * SEGMENTATION IS DELIBERATELY *NOT* POSIX. `splitSegments` reproduces the
41
+ * extension's original splitter bit-for-bit, because `gitCommitPolicyHit` and
42
+ * the write gate consume its exact output: a backslash consumes the next
43
+ * character even inside single quotes, segments are trimmed, empty segments
44
+ * are dropped, and a lone `&` does not split while `&&` does. `tokenizeWords`
45
+ * follows POSIX quoting (`\` inside double quotes escapes only `$`, `` ` ``,
46
+ * `"`, `\` and newline) — which is why `"C:\Program Files\Git\cmd\git.exe"`
47
+ * keeps its backslashes. The two passes coexist on purpose.
48
+ *
49
+ * Dependency-free (no Pi / pi-tui imports), like `utils.ts`, so it stays
50
+ * loadable in plain Node tests.
51
+ */
52
+
53
+ /**
54
+ * Sentinel that replaces a masked character. `\u0001` cannot appear in a
55
+ * shell command a terminal or a model produces, and unlike a space it never
56
+ * matches `\s`, so masking never invents a token boundary.
57
+ */
58
+ const SENTINEL = "\u0001";
59
+
60
+ /** How a word was quoted, for diagnostics and for policy decisions. */
61
+ export type QuoteKind = "none" | "single" | "double" | "mixed";
62
+
63
+ /** One argv word after quote removal. */
64
+ export interface ShellWord {
65
+ /** Exact source slice, quotes and escapes included. */
66
+ raw: string;
67
+ /** The value the process receives (quotes removed, POSIX unescaping). */
68
+ value: string;
69
+ /** Offset of `raw` within `ShellSegment.raw`. */
70
+ start: number;
71
+ /** Offset just past `raw` within `ShellSegment.raw`. */
72
+ end: number;
73
+ quoting: QuoteKind;
74
+ }
75
+
76
+ /** A redirection, which is not an argv word. */
77
+ export interface ShellRedirect {
78
+ /** Operator as written: `>`, `>>`, `<`, `<<`, `2>`, `&>`, `2>&`, `&>>`, … */
79
+ op: string;
80
+ /** Quote-removed target (`/dev/null`, `out.txt`, `1`), when there is one. */
81
+ target?: string;
82
+ /** Offsets within `ShellSegment.raw`. */
83
+ start: number;
84
+ end: number;
85
+ }
86
+
87
+ /** One top-level command of a compound command. */
88
+ export interface ShellSegment {
89
+ /** Trimmed source slice, exactly what `splitSegments` returns. */
90
+ raw: string;
91
+ /** Offset of `raw` within `ShellScan.raw` (absolute). */
92
+ start: number;
93
+ /** Offset just past `raw` within `ShellScan.raw` (absolute). */
94
+ end: number;
95
+ /** ARGV only: operators and redirects are absent (see the module doc). */
96
+ words: ShellWord[];
97
+ /** Redirections, in source order. */
98
+ redirects: ShellRedirect[];
99
+ /** The operator that ended this segment, if any. */
100
+ next?: "|" | ";" | "&&" | "||" | "\n";
101
+ }
102
+
103
+ /** The full analysis of one command string. */
104
+ export interface ShellScan {
105
+ raw: string;
106
+ segments: ShellSegment[];
107
+ /** Same length as `raw`: operator characters that are literal are masked. */
108
+ structural: string;
109
+ /** Same length as `raw`: text the shell will expand (`$(…)`, backticks). */
110
+ expandable: string;
111
+ }
112
+
113
+ /** One segment range, before tokenisation. */
114
+ interface SegmentRange {
115
+ raw: string;
116
+ start: number;
117
+ end: number;
118
+ next?: "|" | ";" | "&&" | "||" | "\n";
119
+ }
120
+
121
+ /**
122
+ * Split a compound command on top-level `|`, `;`, `&&`, `||` and newlines,
123
+ * returning absolute ranges.
124
+ *
125
+ * This is a faithful copy of the extension's original splitter (including its
126
+ * escape-before-quote ordering, which makes a backslash consume the next
127
+ * character even inside single quotes). Do not "fix" it to POSIX here: the
128
+ * git commit classifier and the write gate depend on the exact boundaries.
129
+ */
130
+ function splitSegmentRanges(command: string): SegmentRange[] {
131
+ const out: SegmentRange[] = [];
132
+ let current = "";
133
+ let currentStart = 0;
134
+ let quote: '"' | "'" | undefined;
135
+ let escaped = false;
136
+ const push = (next?: SegmentRange["next"]): void => {
137
+ const trimmed = current.trim();
138
+ if (trimmed) {
139
+ const lead = current.length - current.trimStart().length;
140
+ const start = currentStart + lead;
141
+ out.push({ raw: trimmed, start, end: start + trimmed.length, next });
142
+ }
143
+ current = "";
144
+ currentStart = 0;
145
+ };
146
+ for (let i = 0; i < command.length; i++) {
147
+ const ch = command[i];
148
+ if (!current && !escaped) currentStart = i;
149
+ if (escaped) {
150
+ current += ch;
151
+ escaped = false;
152
+ continue;
153
+ }
154
+ if (ch === "\\") {
155
+ current += ch;
156
+ escaped = true;
157
+ continue;
158
+ }
159
+ if (quote) {
160
+ current += ch;
161
+ if (ch === quote) quote = undefined;
162
+ continue;
163
+ }
164
+ if (ch === '"' || ch === "'") {
165
+ quote = ch;
166
+ current += ch;
167
+ continue;
168
+ }
169
+ if (ch === "\n" || ch === ";") {
170
+ push(ch);
171
+ continue;
172
+ }
173
+ if (ch === "|") {
174
+ const next = command[i + 1] === "|" ? "||" : "|";
175
+ push(next);
176
+ if (command[i + 1] === "|") i++;
177
+ continue;
178
+ }
179
+ if (ch === "&" && command[i + 1] === "&") {
180
+ push("&&");
181
+ i++;
182
+ continue;
183
+ }
184
+ current += ch;
185
+ }
186
+ push(undefined);
187
+ return out;
188
+ }
189
+
190
+ /**
191
+ * Split a compound command into trimmed segments.
192
+ *
193
+ * Quote-aware: `grep -E 'a|b' f` stays one segment. See
194
+ * `splitSegmentRanges` for the deliberate legacy quirks.
195
+ */
196
+ export function splitSegments(command: unknown): string[] {
197
+ const raw = typeof command === "string" ? command : "";
198
+ if (!raw) return [];
199
+ return splitSegmentRanges(raw).map((r) => r.raw);
200
+ }
201
+
202
+ /** Build the two masked views of a command (both same length as `raw`). */
203
+ function maskViews(command: string): {
204
+ structural: string;
205
+ expandable: string;
206
+ } {
207
+ const structural: string[] = [];
208
+ const expandable: string[] = [];
209
+ let quote: '"' | "'" | undefined;
210
+ let i = 0;
211
+ while (i < command.length) {
212
+ const ch = command[i];
213
+ // Single quotes: nothing is an operator and nothing expands.
214
+ if (quote === "'") {
215
+ structural.push(SENTINEL);
216
+ expandable.push(SENTINEL);
217
+ if (ch === "'") quote = undefined;
218
+ i++;
219
+ continue;
220
+ }
221
+ // Double quotes: operators are literal, but expansion still happens.
222
+ if (quote === '"') {
223
+ if (ch === "\\") {
224
+ const nx = command[i + 1];
225
+ if (nx !== undefined && '$`"\\\n'.includes(nx)) {
226
+ structural.push(SENTINEL);
227
+ expandable.push(SENTINEL);
228
+ structural.push(SENTINEL);
229
+ expandable.push(SENTINEL);
230
+ i += 2;
231
+ continue;
232
+ }
233
+ }
234
+ const isOperator = "|&;<>()\n\r".includes(ch);
235
+ structural.push(isOperator || ch === '"' ? SENTINEL : ch);
236
+ expandable.push(ch === '"' ? SENTINEL : ch);
237
+ if (ch === '"') quote = undefined;
238
+ i++;
239
+ continue;
240
+ }
241
+ // Unquoted.
242
+ if (ch === "'" || ch === '"') {
243
+ quote = ch;
244
+ structural.push(SENTINEL);
245
+ expandable.push(SENTINEL);
246
+ i++;
247
+ continue;
248
+ }
249
+ if (ch === "\\") {
250
+ structural.push(SENTINEL);
251
+ expandable.push(SENTINEL);
252
+ if (i + 1 < command.length) {
253
+ structural.push(SENTINEL);
254
+ expandable.push(SENTINEL);
255
+ i += 2;
256
+ } else {
257
+ i++;
258
+ }
259
+ continue;
260
+ }
261
+ structural.push(ch);
262
+ expandable.push(ch);
263
+ i++;
264
+ }
265
+ return { structural: structural.join(""), expandable: expandable.join("") };
266
+ }
267
+
268
+ /** Read a redirect target starting at `k`, honouring simple quoting. */
269
+ function readRedirectTarget(
270
+ segment: string,
271
+ k: number,
272
+ ): { value: string; end: number } | undefined {
273
+ let i = k;
274
+ while (i < segment.length && (segment[i] === " " || segment[i] === "\t")) {
275
+ i++;
276
+ }
277
+ if (i >= segment.length) return undefined;
278
+ const ch = segment[i];
279
+ if (ch === "'" || ch === '"') {
280
+ let j = i + 1;
281
+ let value = "";
282
+ while (j < segment.length && segment[j] !== ch) {
283
+ const c = segment[j];
284
+ if (ch === '"' && c === "\\") {
285
+ const nx = segment[j + 1];
286
+ if (nx !== undefined && '$`"\\\n'.includes(nx)) {
287
+ value += nx;
288
+ j += 2;
289
+ continue;
290
+ }
291
+ }
292
+ value += c;
293
+ j++;
294
+ }
295
+ return { value, end: j < segment.length ? j + 1 : j };
296
+ }
297
+ if (/[\s|&;<>()]/.test(ch)) return undefined;
298
+ let j = i;
299
+ let value = "";
300
+ while (j < segment.length && !/[\s|&;<>()]/.test(segment[j])) {
301
+ value += segment[j];
302
+ j++;
303
+ }
304
+ return { value, end: j };
305
+ }
306
+
307
+ /** The mutable word being assembled by `tokenizeWords`. */
308
+ interface WordBuilder {
309
+ start: number;
310
+ raw: string;
311
+ value: string;
312
+ sawNone: boolean;
313
+ sawSingle: boolean;
314
+ sawDouble: boolean;
315
+ }
316
+
317
+ function quoteKindOf(b: WordBuilder): QuoteKind {
318
+ const kinds = [b.sawNone, b.sawSingle, b.sawDouble].filter(Boolean).length;
319
+ if (kinds > 1) return "mixed";
320
+ if (b.sawSingle) return "single";
321
+ if (b.sawDouble) return "double";
322
+ return "none";
323
+ }
324
+
325
+ /**
326
+ * Tokenise one segment into argv words plus redirects.
327
+ *
328
+ * POSIX quoting: `\` outside quotes escapes the next character; inside single
329
+ * quotes it is literal; inside double quotes it escapes only `$`, `` ` ``,
330
+ * `"`, `\` and newline (so a Windows path keeps its backslashes). Operators
331
+ * and redirects never appear in `words` — that is what makes `words[0]` the
332
+ * command name even when the segment starts with `2>/dev/null`.
333
+ */
334
+ export function tokenizeWords(segment: unknown): {
335
+ words: ShellWord[];
336
+ redirects: ShellRedirect[];
337
+ } {
338
+ const s = typeof segment === "string" ? segment : "";
339
+ const words: ShellWord[] = [];
340
+ const redirects: ShellRedirect[] = [];
341
+ let word: WordBuilder | null = null;
342
+ const flush = (): void => {
343
+ if (!word) return;
344
+ const b = word;
345
+ words.push({
346
+ raw: b.raw,
347
+ value: b.value,
348
+ start: b.start,
349
+ end: b.start + b.raw.length,
350
+ quoting: quoteKindOf(b),
351
+ });
352
+ word = null;
353
+ };
354
+ const begin = (i: number): WordBuilder => ({
355
+ start: i,
356
+ raw: "",
357
+ value: "",
358
+ sawNone: false,
359
+ sawSingle: false,
360
+ sawDouble: false,
361
+ });
362
+
363
+ let i = 0;
364
+ while (i < s.length) {
365
+ const ch = s[i];
366
+
367
+ if (!word && /\s/.test(ch)) {
368
+ i++;
369
+ continue;
370
+ }
371
+
372
+ // Unquoted backslash escape.
373
+ if (ch === "\\") {
374
+ if (!word) word = begin(i);
375
+ const nx = s[i + 1];
376
+ word.raw += ch;
377
+ if (nx !== undefined) {
378
+ word.raw += nx;
379
+ word.value += nx;
380
+ i += 2;
381
+ } else {
382
+ word.value += ch;
383
+ i += 1;
384
+ }
385
+ word.sawNone = true;
386
+ continue;
387
+ }
388
+
389
+ // Single-quoted span: fully literal.
390
+ if (ch === "'") {
391
+ if (!word) word = begin(i);
392
+ word.sawSingle = true;
393
+ word.raw += ch;
394
+ i++;
395
+ while (i < s.length && s[i] !== "'") {
396
+ word.raw += s[i];
397
+ word.value += s[i];
398
+ i++;
399
+ }
400
+ if (i < s.length) {
401
+ word.raw += "'";
402
+ i++;
403
+ }
404
+ continue;
405
+ }
406
+
407
+ // Double-quoted span: expansion happens, backslash is narrow (POSIX).
408
+ if (ch === '"') {
409
+ if (!word) word = begin(i);
410
+ word.sawDouble = true;
411
+ word.raw += ch;
412
+ i++;
413
+ while (i < s.length && s[i] !== '"') {
414
+ const c = s[i];
415
+ if (c === "\\") {
416
+ const nx = s[i + 1];
417
+ if (nx !== undefined && '$`"\\\n'.includes(nx)) {
418
+ word.raw += c + nx;
419
+ word.value += nx;
420
+ i += 2;
421
+ continue;
422
+ }
423
+ word.raw += c;
424
+ word.value += c;
425
+ i++;
426
+ continue;
427
+ }
428
+ word.raw += c;
429
+ word.value += c;
430
+ i++;
431
+ }
432
+ if (i < s.length) {
433
+ word.raw += '"';
434
+ i++;
435
+ }
436
+ continue;
437
+ }
438
+
439
+ // `&>` / `&>>` — a redirect written with the ampersand first.
440
+ if (ch === "&" && s[i + 1] === ">") {
441
+ flush();
442
+ let op = "&>";
443
+ let j = i + 2;
444
+ if (s[j] === ">") {
445
+ op = "&>>";
446
+ j++;
447
+ }
448
+ const target = readRedirectTarget(s, j);
449
+ redirects.push({ op, target: target?.value, start: i, end: target?.end ?? j });
450
+ i = target?.end ?? j;
451
+ continue;
452
+ }
453
+
454
+ // `>` / `>>` / `<` / `<<`, with an optional fd prefix and `>&`/`<&`.
455
+ if (ch === ">" || ch === "<") {
456
+ let fdPrefix = "";
457
+ if (word && word.sawNone && /^\d+$/.test(word.value)) {
458
+ fdPrefix = word.value;
459
+ word = null;
460
+ } else {
461
+ flush();
462
+ }
463
+ let op = ch;
464
+ let j = i + 1;
465
+ if (s[j] === ch) {
466
+ op += ch;
467
+ j++;
468
+ }
469
+ if (s[j] === "&") {
470
+ op += "&";
471
+ j++;
472
+ let target = "";
473
+ while (j < s.length && /[0-9-]/.test(s[j])) {
474
+ target += s[j];
475
+ j++;
476
+ }
477
+ redirects.push({ op: fdPrefix + op, target, start: i, end: j });
478
+ i = j;
479
+ continue;
480
+ }
481
+ const target = readRedirectTarget(s, j);
482
+ redirects.push({
483
+ op: fdPrefix + op,
484
+ target: target?.value,
485
+ start: i,
486
+ end: target?.end ?? j,
487
+ });
488
+ i = target?.end ?? j;
489
+ continue;
490
+ }
491
+
492
+ // Whitespace ends a word. A lone `&` is a control operator, not a word.
493
+ if (/\s/.test(ch)) {
494
+ flush();
495
+ i++;
496
+ continue;
497
+ }
498
+
499
+ if (!word) word = begin(i);
500
+ word.raw += ch;
501
+ word.value += ch;
502
+ word.sawNone = true;
503
+ i++;
504
+ }
505
+ flush();
506
+ return { words, redirects };
507
+ }
508
+
509
+ /**
510
+ * Analyse a command once: segments, argv words, redirects and the two masked
511
+ * views. Total and never throws — non-strings analyse as empty.
512
+ */
513
+ export function scanShell(command: unknown): ShellScan {
514
+ const raw = typeof command === "string" ? command : "";
515
+ const { structural, expandable } = maskViews(raw);
516
+ const segments: ShellSegment[] = splitSegmentRanges(raw).map((r) => {
517
+ const { words, redirects } = tokenizeWords(r.raw);
518
+ const seg: ShellSegment = {
519
+ raw: r.raw,
520
+ start: r.start,
521
+ end: r.end,
522
+ words,
523
+ redirects,
524
+ };
525
+ if (r.next) seg.next = r.next;
526
+ return seg;
527
+ });
528
+ return { raw, segments, structural, expandable };
529
+ }
530
+
531
+ /** The sentinel used by the masked views (exported for tests). */
532
+ export const SHELL_MASK_SENTINEL = SENTINEL;
533
+
534
+ /** True when `text` contains only spaces before `index` (command position). */
535
+ export function atCommandPosition(text: string, index: number): boolean {
536
+ return text.slice(0, index).trim() === "";
537
+ }
538
+
539
+ /** A segment's argv joined by single spaces, for flag/keyword matching. */
540
+ export function wordsText(words: readonly ShellWord[]): string {
541
+ return words.map((w) => w.value).join(" ");
542
+ }
package/utils.ts CHANGED
@@ -931,6 +931,48 @@ export function isPlanWritePath(p: string, cwd: string): boolean {
931
931
  return norm.includes(".pi/plans/") || norm.startsWith(cwdNorm + "/.pi/plans");
932
932
  }
933
933
 
934
+ /**
935
+ * Whether two plan paths identify the same file.
936
+ *
937
+ * Plan targets arrive in several shapes: absolute Windows paths from a
938
+ * `tool_result`, relative paths from `/review <path>`, forward slashes from a
939
+ * handoff, and case differences from the host. A review-cycle boundary must
940
+ * not fire for a spelling difference, and must fire for a real move. Kept in
941
+ * this dependency-free module so the comparison is unit-testable; callers pass
942
+ * the session cwd for relative paths.
943
+ */
944
+ export function samePlanPath(
945
+ a: string | null | undefined,
946
+ b: string | null | undefined,
947
+ cwd: string = process.cwd(),
948
+ ): boolean {
949
+ const normalize = (raw: string | null | undefined): string => {
950
+ let value = String(raw ?? "").trim();
951
+ if (!value) return "";
952
+ value = value.replace(/\\/g, "/");
953
+ const absolute =
954
+ value.startsWith("/") || /^[A-Za-z]:\//.test(value);
955
+ if (!absolute) {
956
+ value = `${String(cwd ?? "").replace(/\\/g, "/").replace(/\/+$/, "")}/${value}`;
957
+ }
958
+ const parts: string[] = [];
959
+ for (const part of value.split("/")) {
960
+ if (!part || part === ".") continue;
961
+ if (part === "..") {
962
+ if (parts.length > 0) parts.pop();
963
+ continue;
964
+ }
965
+ parts.push(part);
966
+ }
967
+ let out = parts.join("/");
968
+ if (value.startsWith("/")) out = `/${out}`;
969
+ // Windows drive letters are case-insensitive; keep POSIX case-sensitive.
970
+ out = out.replace(/^([A-Za-z]):/, (_m, drive: string) => `${drive.toUpperCase()}:`);
971
+ return process.platform === "win32" ? out.toLowerCase() : out;
972
+ };
973
+ return normalize(a) === normalize(b);
974
+ }
975
+
934
976
  /** Inputs for deciding whether this run may hand a plan off. */
935
977
  export interface PlanHandoffEligibility {
936
978
  mode: "plan" | "build" | null;