@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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@dieulc/workflow",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
7
7
  "description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
8
8
  "license": "MIT",
9
9
  "scripts": {
10
- "test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
10
+ "test": "node --test utils.todo.test.ts permissions.test.ts shell-text.test.ts roles.test.ts profiles.test.ts provider-retarget.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-handoff.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json",
12
12
  "check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs",
13
13
  "prepublishOnly": "node ../../../scripts/verify-packages.mjs --pack-only --packages workflow"
@@ -41,8 +41,12 @@
41
41
  "questionnaire-view.ts",
42
42
  "model-default.ts",
43
43
  "permissions.ts",
44
+ "shell-text.ts",
44
45
  "review.ts",
45
46
  "review-providers.ts",
47
+ "review-recovery.ts",
48
+ "review-handoff.ts",
49
+ "provider-retarget.ts",
46
50
  "review-tools.ts",
47
51
  "review-runtime.ts",
48
52
  "review-ui.ts",
@@ -64,10 +68,10 @@
64
68
  "typebox": "*"
65
69
  },
66
70
  "devDependencies": {
67
- "@earendil-works/pi-agent-core": "0.84.4",
68
- "@earendil-works/pi-ai": "0.84.4",
69
- "@earendil-works/pi-coding-agent": "0.84.4",
70
- "@earendil-works/pi-tui": "0.84.4",
71
+ "@earendil-works/pi-agent-core": "1.5.0",
72
+ "@earendil-works/pi-ai": "1.5.0",
73
+ "@earendil-works/pi-coding-agent": "1.5.0",
74
+ "@earendil-works/pi-tui": "1.5.0",
71
75
  "typebox": "1.3.7",
72
76
  "@types/node": "^20.19.43",
73
77
  "typescript": "5.5.4"
package/permissions.ts CHANGED
@@ -15,6 +15,17 @@
15
15
  * stays loadable in plain Node tests, exactly like `utils.ts`.
16
16
  */
17
17
 
18
+ import {
19
+ atCommandPosition,
20
+ scanShell,
21
+ splitSegments,
22
+ tokenizeWords,
23
+ wordsText,
24
+ type ShellRedirect,
25
+ type ShellWord,
26
+ } from "./shell-text.ts";
27
+ import type { ArgumentCompletion } from "./utils.ts";
28
+
18
29
  export type CommandClass = "read-only" | "verify" | "full";
19
30
 
20
31
  export type WorkflowRole = "planner" | "reviewer" | "explorer" | "builder";
@@ -371,8 +382,10 @@ const BLOCKED_SCRIPT_SUFFIX_RE = /[:._-](fix|write)([:._-]|$)/i;
371
382
  // ── Dangerous syntax / patterns ──────────────────────────────────────
372
383
 
373
384
  /**
374
- * Hard denylist. Applied to the whole raw command (quote-insensitive, like the
375
- * original implementation) before any allowlist classification.
385
+ * Hard denylist, matched in COMMAND POSITION against a segment's argv (see
386
+ * `destructiveHit`): a keyword inside an argument of a read-only command is
387
+ * data, not a command. Every binary that executes its arguments is class
388
+ * `full`, so the anywhere-pass still covers `env rm`, `bash -c`, `xargs`.
376
389
  *
377
390
  * The git entries are deliberately narrowed so the read-only forms the
378
391
  * allowlist exposes are actually reachable: `git tag -l` / `--list`,
@@ -573,21 +586,37 @@ export function stripHarmlessRedirects(command: unknown): string {
573
586
  * Why a command's syntax cannot be classified (`null` = the syntax is fine).
574
587
  * Mirrors the order the gate has always used, so a refusal can name the first
575
588
  * failing construct instead of only saying "blocked".
589
+ *
590
+ * Two views, because the shell treats the two classes of construct
591
+ * differently (`shell-text.ts`):
592
+ * - OPERATORS (`&`, `>`, `>>`, `<<`, `<(`/`>(`) are operators only OUTSIDE
593
+ * quotes, so they are read from the `structural` view. `rg 'a > b' f`
594
+ * writes nothing and `rg '<<' f` reads nothing.
595
+ * - EXPANSIONS (`$(…)`, backticks, `${…}`) happen OUTSIDE single quotes but
596
+ * DO happen inside double quotes, so they are read from the `expandable`
597
+ * view. `rg '$(id)' f` is a literal search; `rg "$(id)" f` executes.
598
+ *
599
+ * The harmless-redirect exemptions (`2>&1`, `1>&2`, `> /dev/null`) are
600
+ * applied to the structural view BEFORE the tests, exactly as before: they
601
+ * are real redirects, they just write nothing the user can see.
576
602
  */
577
603
  export function forbiddenSyntaxReason(command: unknown): string | null {
578
604
  const raw = typeof command === "string" ? command : "";
605
+ if (!raw) return null;
606
+ const scan = scanShell(raw);
579
607
  // `2>&1` / `1>&2` are fd duplications, not file writes; `/dev/null` targets
580
- // are a bit bucket, not a file.
581
- const scrubbed = stripHarmlessRedirects(raw);
582
- if (scrubbed.replace(/&&/g, " ").includes("&"))
608
+ // are a bit bucket, not a file. Masked (quoted) text is already non-
609
+ // structural, so a quoted `2>&1` needs no exemption.
610
+ const structural = stripHarmlessRedirects(scan.structural);
611
+ if (structural.replace(/&&/g, " ").includes("&"))
583
612
  return "a background `&` (only `&&` chains are allowed)";
584
- if (raw.includes("$(")) return "command substitution `$(...)`";
585
- if (raw.includes("`")) return "backtick command substitution";
586
- if (raw.includes("${")) return "parameter expansion `${...}`";
587
- if (raw.includes("<(") || raw.includes(">("))
613
+ if (scan.expandable.includes("$(")) return "command substitution `$(...)`";
614
+ if (scan.expandable.includes("`")) return "backtick command substitution";
615
+ if (scan.expandable.includes("${")) return "parameter expansion `${...}`";
616
+ if (structural.includes("<(") || structural.includes(">("))
588
617
  return "process substitution `<(...)` / `>(...)`";
589
- if (raw.includes("<<")) return "a heredoc `<<`";
590
- if (/(^|[^<&])>(?!>|&)/.test(scrubbed))
618
+ if (structural.includes("<<")) return "a heredoc `<<`";
619
+ if (/(^|[^<&])>(?!>|&)/.test(structural))
591
620
  return "a file redirect (`>` / `>>`); only `/dev/null` and `2>&1` are allowed";
592
621
  return null;
593
622
  }
@@ -605,67 +634,40 @@ export function hasForbiddenSyntax(command: string): boolean {
605
634
  /**
606
635
  * Split a compound command on top-level `|`, `;`, `&&`, `||` and newlines.
607
636
  * Quote-aware: `grep -E 'a|b' f` stays one segment.
637
+ *
638
+ * Delegates to `shell-text.ts` so the policy layer has ONE definition of a
639
+ * segment boundary. `splitSegments` reproduces this function's original
640
+ * behaviour exactly (including its escape-before-quote ordering); only the
641
+ * implementation moved.
608
642
  */
609
643
  export function splitCommandSegments(command: string): string[] {
610
- const segments: string[] = [];
611
- let current = "";
612
- let quote: '"' | "'" | undefined;
613
- let escaped = false;
614
- const push = (): void => {
615
- const s = current.trim();
616
- if (s) segments.push(s);
617
- current = "";
618
- };
619
- for (let i = 0; i < command.length; i++) {
620
- const ch = command[i];
621
- if (escaped) {
622
- current += ch;
623
- escaped = false;
624
- continue;
625
- }
626
- if (ch === "\\") {
627
- current += ch;
628
- escaped = true;
629
- continue;
630
- }
631
- if (quote) {
632
- current += ch;
633
- if (ch === quote) quote = undefined;
634
- continue;
635
- }
636
- if (ch === '"' || ch === "'") {
637
- quote = ch;
638
- current += ch;
639
- continue;
640
- }
641
- if (ch === "\n" || ch === ";") {
642
- push();
643
- continue;
644
- }
645
- if (ch === "|") {
646
- push();
647
- if (command[i + 1] === "|") i++;
648
- continue;
649
- }
650
- if (ch === "&" && command[i + 1] === "&") {
651
- push();
652
- i++;
653
- continue;
654
- }
655
- current += ch;
656
- }
657
- push();
658
- return segments;
644
+ return splitSegments(command);
659
645
  }
660
646
 
661
647
  // ── Classification ───────────────────────────────────────────────────
662
648
 
663
649
  /** Classify one already-split segment. Unknown input → `full` (fail closed). */
664
650
  export function commandClassOf(segment: string): CommandClass {
651
+ return classifyWords(tokenizeWords(segment).words, segment);
652
+ }
653
+
654
+ /**
655
+ * Classify a segment whose argv words are already tokenised.
656
+ *
657
+ * The dispatch reads `words[0]` — the command name AFTER quote removal and
658
+ * with redirects excluded, so `2>/dev/null ls` classifies as `ls` instead of
659
+ * as an unknown binary. The branch helpers keep taking the segment text: they
660
+ * inspect flags and script names, and rewriting all of them would mix this
661
+ * change with an unrelated refactor. `commandClassOf(string)` stays the
662
+ * public entry point (D6).
663
+ */
664
+ function classifyWords(
665
+ words: readonly ShellWord[],
666
+ segment: string,
667
+ ): CommandClass {
665
668
  const s = String(segment ?? "").trim();
666
669
  if (!s) return "full";
667
- const first = firstWord(s);
668
-
670
+ const first = normalizeBinary(words[0]?.value ?? "") || firstWord(s);
669
671
  if (!first) return "full";
670
672
  if (INTERPRETER_EXECUTABLES.has(first)) return "full";
671
673
 
@@ -1041,29 +1043,230 @@ function classifyCargo(s: string): CommandClass {
1041
1043
  // ── Public gate ──────────────────────────────────────────────────────
1042
1044
 
1043
1045
  /**
1044
- * The first destructive pattern (or writing flag) that matches, with the exact
1045
- * text that matched so a refusal can name it (`rm`, `npm install`, `--fix`).
1046
+ * The two ways a segment can be denied outright (see `destructiveHit`).
1046
1047
  */
1047
- function destructiveMatch(
1048
- command: string,
1049
- ): { kind: "destructive-pattern" | "blocked-flag"; label: string } | undefined {
1050
- const hit = DESTRUCTIVE_PATTERNS.find((p) => p.test(command));
1051
- if (hit) {
1052
- const matched = command.match(hit)?.[0]?.trim() ?? "destructive keyword";
1053
- return { kind: "destructive-pattern", label: `\`${matched}\`` };
1048
+ type DestructiveKind = "destructive-pattern" | "blocked-flag";
1049
+
1050
+ /** One segment as the destructive denylist needs to see it. */
1051
+ interface DestructiveInput {
1052
+ words: readonly ShellWord[];
1053
+ commandClass: CommandClass;
1054
+ }
1055
+
1056
+ /**
1057
+ * Position/class-aware destructive match.
1058
+ *
1059
+ * The denylist is defence-in-depth: a command that can EXECUTE its
1060
+ * arguments is already class `full` and `full` is refused for every
1061
+ * restricted role, so the keyword layer only has to catch what the
1062
+ * classifier could have got wrong. Matching it against raw text — the old
1063
+ * behaviour — made the canonical exploration queries impossible:
1064
+ * `rg -n "rm -rf" src` was refused because the PATTERN contained a keyword.
1065
+ *
1066
+ * The rule (`shell-text.ts` gives the argv words):
1067
+ * 1. A pattern counts when it matches at the START of a segment's argv —
1068
+ * the command position. `rm -rf x`, `npm install`, `git commit` hit;
1069
+ * `rg -n rm -rf src` does not.
1070
+ * 2. It also counts ANYWHERE in a segment already classified `full`: that
1071
+ * segment is refused regardless, and the more specific label makes the
1072
+ * refusal explain itself (`env rm -rf /`, `bash -c "rm -rf /"`).
1073
+ * 3. Patterns are checked across every segment BEFORE any flag, preserving
1074
+ * the original precedence (`npm install` reports a pattern, not a flag).
1075
+ *
1076
+ * Flags are matched against word VALUES, not raw text, because quote removal
1077
+ * makes `find . "-delete"` a real flag.
1078
+ */
1079
+ function destructiveHit(
1080
+ segments: readonly DestructiveInput[],
1081
+ ): { kind: DestructiveKind; label: string; index: number } | undefined {
1082
+ // 1. Patterns in command position.
1083
+ for (let i = 0; i < segments.length; i++) {
1084
+ const text = wordsText(segments[i].words);
1085
+ if (!text) continue;
1086
+ for (const pattern of DESTRUCTIVE_PATTERNS) {
1087
+ const m = pattern.exec(text);
1088
+ if (m && atCommandPosition(text, m.index)) {
1089
+ return {
1090
+ kind: "destructive-pattern",
1091
+ label: `\`${m[0].trim()}\``,
1092
+ index: i,
1093
+ };
1094
+ }
1095
+ }
1096
+ }
1097
+ // 2. Patterns anywhere in a segment that is already unrestricted.
1098
+ for (let i = 0; i < segments.length; i++) {
1099
+ const seg = segments[i];
1100
+ if (seg.commandClass !== "full") continue;
1101
+ const text = wordsText(seg.words);
1102
+ if (!text) continue;
1103
+ for (const pattern of DESTRUCTIVE_PATTERNS) {
1104
+ const m = pattern.exec(text);
1105
+ if (m) {
1106
+ return {
1107
+ kind: "destructive-pattern",
1108
+ label: `\`${m[0].trim()}\``,
1109
+ index: i,
1110
+ };
1111
+ }
1112
+ }
1113
+ }
1114
+ // 3. A write flag on an otherwise-safe command (`eslint --fix`).
1115
+ for (let i = 0; i < segments.length; i++) {
1116
+ const m = DENIED_FLAG_RE.exec(wordsText(segments[i].words));
1117
+ if (m) {
1118
+ return {
1119
+ kind: "blocked-flag",
1120
+ label: `the flag \`${m[2] ?? m[0].trim()}\``,
1121
+ index: i,
1122
+ };
1123
+ }
1054
1124
  }
1055
- const flag = command.match(DENIED_FLAG_RE);
1056
- if (flag)
1057
- return {
1058
- kind: "blocked-flag",
1059
- label: `the flag \`${flag[2] ?? flag[0].trim()}\``,
1060
- };
1061
1125
  return undefined;
1062
1126
  }
1063
1127
 
1128
+ /** Classify a command's segments the way the destructive denylist needs. */
1129
+ function destructiveInputsOf(command: unknown): DestructiveInput[] {
1130
+ const scan = scanShell(command);
1131
+ return scan.segments.map((seg) => ({
1132
+ words: seg.words,
1133
+ commandClass: commandClassOf(seg.raw),
1134
+ }));
1135
+ }
1136
+
1064
1137
  /** True when a segment is denied outright (destructive syntax or writing flags). */
1065
1138
  export function isDestructiveCommand(command: string): boolean {
1066
- return destructiveMatch(command) !== undefined;
1139
+ return destructiveHit(destructiveInputsOf(command)) !== undefined;
1140
+ }
1141
+
1142
+ // ── One analysis, two gates ──────────────────────────────────────────
1143
+
1144
+ /** One segment, with both the role verdict and the write verdict. */
1145
+ export interface SegmentAnalysis {
1146
+ /** Trimmed segment text (what `splitCommandSegments` returns). */
1147
+ raw: string;
1148
+ /** ARGV words after quote removal; operators and redirects excluded. */
1149
+ words: ShellWord[];
1150
+ /** Redirections of this segment (not argv). */
1151
+ redirects: ShellRedirect[];
1152
+ /** The command name, normalized (`git`, `npm`, `ls`). */
1153
+ command: string;
1154
+ /** Highest capability this segment needs. */
1155
+ commandClass: CommandClass;
1156
+ /** The write flag found on this segment, e.g. `--fix`. */
1157
+ writeFlag?: string;
1158
+ /** The git subcommand when this is a commit-policy operation. */
1159
+ gitCommit: CommitPolicyHit | null;
1160
+ /** The destructive pattern/flag that names this segment, when it is one. */
1161
+ destructive?: { kind: DestructiveKind; label: string };
1162
+ /** True when this segment may modify the filesystem. */
1163
+ mutating: boolean;
1164
+ /** Human cause, present when `mutating`. */
1165
+ writeReason?: string;
1166
+ }
1167
+
1168
+ /** The single analysis both gates consume (see `analyzeCommand`). */
1169
+ export interface CommandAnalysis {
1170
+ raw: string;
1171
+ /** The shell construct that cannot be classified, when there is one. */
1172
+ forbiddenSyntax?: string;
1173
+ segments: SegmentAnalysis[];
1174
+ /** The first destructive hit across all segments, for the refusal message. */
1175
+ destructive?: { kind: DestructiveKind; label: string };
1176
+ /** True when ANY segment may modify the filesystem. */
1177
+ mutating: boolean;
1178
+ writeReason?: string;
1179
+ writeSegment?: string;
1180
+ }
1181
+
1182
+ /**
1183
+ * Analyse a command ONCE for both gates.
1184
+ *
1185
+ * This is the single pipeline the role gate (`explainCommandRefusal`) and the
1186
+ * write gate (`commandMayWrite` in edit-policy.ts) share, so their answers can
1187
+ * no longer disagree about the same text.
1188
+ *
1189
+ * Per segment the write verdict follows the order the write policy has always
1190
+ * documented (`edit-policy.ts`, "commandMayWrite"):
1191
+ * 1. forbidden shell syntax → may write (a redirect is a write whatever else
1192
+ * runs; checked first so `git commit -m x > log` is still a write)
1193
+ * 2. `git add|commit|push` → NOT a file write: the commit policy owns them
1194
+ * 3. an unrestricted command class (`full`) → may write. Fail-closed default:
1195
+ * anything not provably read-only (interpreters, unknown binaries) may
1196
+ * write.
1197
+ * 4. a write flag on a verify-class command (`eslint --fix`) → may write
1198
+ * 5. otherwise → read-only (`ls`, `rg`, `git status`, `npm test`,
1199
+ * `tsc --noEmit`, and harmless redirects to `/dev/null` / `2>&1`).
1200
+ *
1201
+ * Classification runs on `stripHarmlessRedirects(segment)`, exactly as before:
1202
+ * the `/dev/null` and `2>&1` exemptions are load-bearing (they keep
1203
+ * `2>/dev/null ls` and `npm test 2> /dev/null | tail -5` read-only).
1204
+ *
1205
+ * Total and never throws: non-strings analyse as empty.
1206
+ */
1207
+ export function analyzeCommand(command: unknown): CommandAnalysis {
1208
+ const raw = typeof command === "string" ? command : "";
1209
+ const trimmed = raw.trim();
1210
+ if (!trimmed) return { raw, segments: [], mutating: false };
1211
+
1212
+ const scan = scanShell(trimmed);
1213
+ const forbiddenSyntax = forbiddenSyntaxReason(trimmed) ?? undefined;
1214
+ const segments: SegmentAnalysis[] = [];
1215
+ let mutating = false;
1216
+ let writeReason: string | undefined;
1217
+ let writeSegment: string | undefined;
1218
+
1219
+ for (const seg of scan.segments) {
1220
+ const scrubbed = stripHarmlessRedirects(seg.raw).trim();
1221
+ const gitCommit = scrubbed ? gitCommitPolicyHit(scrubbed) : null;
1222
+ const commandClass = commandClassOf(scrubbed || seg.raw);
1223
+ const flag = DENIED_FLAG_RE.exec(wordsText(seg.words));
1224
+ const segment: SegmentAnalysis = {
1225
+ raw: seg.raw,
1226
+ words: seg.words,
1227
+ redirects: seg.redirects,
1228
+ command: normalizeBinary(seg.words[0]?.value ?? ""),
1229
+ commandClass,
1230
+ gitCommit,
1231
+ mutating: false,
1232
+ };
1233
+ if (flag) segment.writeFlag = flag[2] ?? flag[0].trim();
1234
+
1235
+ const syntax = forbiddenSyntaxReason(seg.raw);
1236
+ if (syntax) {
1237
+ segment.mutating = true;
1238
+ segment.writeReason = syntax;
1239
+ } else if (!scrubbed) {
1240
+ // Nothing but harmless redirections: writes no file.
1241
+ } else if (gitCommit) {
1242
+ // The commit policy governs git add/commit/push; not a file write.
1243
+ } else if (commandClass === "full") {
1244
+ segment.mutating = true;
1245
+ segment.writeReason = "runs a command that may modify files";
1246
+ } else if (flag) {
1247
+ segment.mutating = true;
1248
+ segment.writeReason = `uses the write flag ${flag[2] ?? flag[0].trim()}`;
1249
+ }
1250
+
1251
+ if (!mutating && segment.mutating) {
1252
+ mutating = true;
1253
+ writeReason = segment.writeReason;
1254
+ writeSegment = seg.raw;
1255
+ }
1256
+ segments.push(segment);
1257
+ }
1258
+
1259
+ const hit = destructiveHit(segments);
1260
+ if (hit) {
1261
+ segments[hit.index].destructive = { kind: hit.kind, label: hit.label };
1262
+ }
1263
+
1264
+ const analysis: CommandAnalysis = { raw, segments, mutating };
1265
+ if (forbiddenSyntax) analysis.forbiddenSyntax = forbiddenSyntax;
1266
+ if (hit) analysis.destructive = { kind: hit.kind, label: hit.label };
1267
+ if (writeReason) analysis.writeReason = writeReason;
1268
+ if (writeSegment) analysis.writeSegment = writeSegment;
1269
+ return analysis;
1067
1270
  }
1068
1271
 
1069
1272
  function classAllows(policyClass: CommandClass, needed: CommandClass): boolean {
@@ -1092,6 +1295,9 @@ export interface CommandRefusal {
1092
1295
  * The single source of truth for the gate: `null` means allowed. Keeping the
1093
1296
  * decision and the explanation in one function stops a refusal message from
1094
1297
  * describing a policy the gate no longer enforces.
1298
+ *
1299
+ * Reads ONE `analyzeCommand` result, so this gate and the write policy cannot
1300
+ * disagree about quoting, redirects or command position.
1095
1301
  */
1096
1302
  export function explainCommandRefusal(
1097
1303
  role: WorkflowRole,
@@ -1105,36 +1311,40 @@ export function explainCommandRefusal(
1105
1311
  : { kind: "empty", detail: "the command is empty" };
1106
1312
  if (policy.commandClass === "full") return null;
1107
1313
 
1108
- const syntax = forbiddenSyntaxReason(raw);
1109
- if (syntax)
1314
+ const analysis = analyzeCommand(raw);
1315
+
1316
+ if (analysis.forbiddenSyntax)
1110
1317
  return {
1111
1318
  kind: "forbidden-syntax",
1112
- detail: `forbidden shell syntax: ${syntax}`,
1113
- suggestion: syntax.includes("redirect")
1319
+ detail: `forbidden shell syntax: ${analysis.forbiddenSyntax}`,
1320
+ suggestion: analysis.forbiddenSyntax.includes("redirect")
1114
1321
  ? "drop the redirect — `cmd` already captures stdout and `cmd 2>&1` also captures stderr; only `2>/dev/null` and `2>&1` are exempt"
1115
1322
  : "use an allowlisted read-only/verify command, or /build for full shell access",
1116
1323
  };
1117
1324
 
1118
- const destructive = destructiveMatch(raw);
1119
- if (destructive)
1325
+ if (analysis.destructive) {
1326
+ const { kind, label } = analysis.destructive;
1120
1327
  return {
1121
- kind: destructive.kind,
1122
- detail: `${destructive.label} matched the raw command text (quoting does not exempt it)`,
1328
+ kind,
1329
+ detail:
1330
+ kind === "blocked-flag"
1331
+ ? `${label} can rewrite files`
1332
+ : `${label} matched the destructive denylist`,
1123
1333
  suggestion:
1124
1334
  "remove or rename the argument, or use /build for unrestricted access",
1125
1335
  };
1336
+ }
1126
1337
 
1127
- const segments = splitCommandSegments(raw);
1128
- if (segments.length === 0)
1338
+ if (analysis.segments.length === 0)
1129
1339
  return { kind: "empty", detail: "no command segments found" };
1130
- for (const segment of segments) {
1131
- const needed = commandClassOf(segment);
1132
- if (classAllows(policy.commandClass, needed)) continue;
1133
- const head = firstWord(segment) || segment.slice(0, 40);
1340
+ for (const segment of analysis.segments) {
1341
+ if (classAllows(policy.commandClass, segment.commandClass)) continue;
1342
+ const needed = segment.commandClass;
1343
+ const head = segment.command || segment.raw.slice(0, 40);
1134
1344
  return {
1135
1345
  kind: "segment-class",
1136
1346
  detail: `\`${head}\` needs ${needed === "full" ? "unrestricted" : needed} access, above the ${policy.commandClass} limit`,
1137
- segment,
1347
+ segment: segment.raw,
1138
1348
  suggestion: `${policy.role} may run ${policy.description}; see README "Permissions" for allowlisted alternatives, or use /build`,
1139
1349
  };
1140
1350
  }
@@ -1158,3 +1368,93 @@ export function describeRolePolicy(role: WorkflowRole): string {
1158
1368
  const policy = policyForRole(role);
1159
1369
  return `${policy.role} may run ${policy.description}`;
1160
1370
  }
1371
+
1372
+ // ── Diagnostics: "/permissions explain" ─────────────────────────────
1373
+
1374
+ /**
1375
+ * Explain a command's verdict under every policy, for `/permissions explain`.
1376
+ *
1377
+ * Pure and side-effect free: it reads the same `analyzeCommand` result and
1378
+ * the same `explainCommandRefusal` the gates use, so the answer cannot
1379
+ * describe a policy the gate no longer enforces. Returns lines ready for
1380
+ * `ctx.ui.notify`; the first line is always the command (or the usage).
1381
+ */
1382
+ export function describeCommandPolicy(command: unknown): string[] {
1383
+ const raw = typeof command === "string" ? command.trim() : "";
1384
+ if (!raw) {
1385
+ return [
1386
+ "Usage: /permissions explain <command>",
1387
+ " Shows each segment's command class, the Plan/Review/Explorer verdict,",
1388
+ " and whether the write policy would treat it as a write.",
1389
+ " Example: /permissions explain rg -n 'a > b' docs",
1390
+ ];
1391
+ }
1392
+
1393
+ const analysis = analyzeCommand(raw);
1394
+ const lines: string[] = [`Command: ${raw}`];
1395
+ for (const reason of [
1396
+ analysis.forbiddenSyntax
1397
+ ? `forbidden shell syntax: ${analysis.forbiddenSyntax}`
1398
+ : undefined,
1399
+ analysis.destructive
1400
+ ? `destructive denylist: ${analysis.destructive.label}`
1401
+ : undefined,
1402
+ ]) {
1403
+ if (reason) lines.push(` ✖ ${reason}`);
1404
+ }
1405
+
1406
+ lines.push(`Segments (${analysis.segments.length}):`);
1407
+ analysis.segments.forEach((seg, index) => {
1408
+ const flags = [seg.commandClass, seg.mutating ? "write" : "no write"];
1409
+ if (seg.writeFlag) flags.push(`flag ${seg.writeFlag}`);
1410
+ if (seg.gitCommit) flags.push(`git ${seg.gitCommit} (commit policy)`);
1411
+ lines.push(` ${index + 1}. ${seg.raw}`, ` ${flags.join(" · ")}`);
1412
+ });
1413
+
1414
+ lines.push("Role verdicts:");
1415
+ for (const role of ["planner", "reviewer", "explorer"] as const) {
1416
+ const refusal = explainCommandRefusal(role, raw);
1417
+ lines.push(
1418
+ refusal
1419
+ ? ` ${role.padEnd(9)} ✖ ${refusal.kind} — ${refusal.detail}`
1420
+ : ` ${role.padEnd(9)} ✔ allowed`,
1421
+ );
1422
+ }
1423
+
1424
+ lines.push(
1425
+ analysis.mutating
1426
+ ? `Default write policy: write — ${analysis.writeReason ?? "may modify files"}`
1427
+ : "Default write policy: read-only — no prompt",
1428
+ );
1429
+ return lines;
1430
+ }
1431
+
1432
+ /**
1433
+ * Argument completions for `/permissions`.
1434
+ *
1435
+ * Follows the pi-tui full-argument contract used by the other policy
1436
+ * commands: the first token completes the subcommand and the returned value
1437
+ * replaces the whole typed argument. The command text itself is free-form, so
1438
+ * nothing is offered past the subcommand.
1439
+ *
1440
+ * The exact-match rule is `suppressExactArgumentMatch` (utils.ts) inlined: a
1441
+ * `utils.ts` import would add a runtime cycle (utils imports this module for
1442
+ * `isSafeCommand`). Same behaviour — typing the whole word closes the list, so
1443
+ * the first Enter submits.
1444
+ */
1445
+ export function permissionsArgumentCompletions(
1446
+ argumentPrefix: string,
1447
+ ): ArgumentCompletion[] | null {
1448
+ const raw = String(argumentPrefix ?? "");
1449
+ if (/\s/.test(raw.trim())) return null;
1450
+ const lower = raw.trim().toLowerCase();
1451
+ if (!"explain".startsWith(lower)) return null;
1452
+ if (lower === "explain") return null;
1453
+ return [
1454
+ {
1455
+ value: "explain ",
1456
+ label: "explain",
1457
+ description: "show why a command would be allowed or refused",
1458
+ },
1459
+ ];
1460
+ }