@dieulc/workflow 0.9.1 → 0.9.2
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 +14 -9
- package/edit-policy.ts +11 -64
- package/index.ts +309 -45
- package/package.json +3 -2
- package/permissions.ts +393 -93
- package/review.ts +80 -3
- package/shell-text.ts +542 -0
- package/utils.ts +42 -0
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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 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",
|
|
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,6 +41,7 @@
|
|
|
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",
|
|
46
47
|
"review-tools.ts",
|
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
|
|
375
|
-
*
|
|
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
|
-
|
|
582
|
-
|
|
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 (
|
|
585
|
-
if (
|
|
586
|
-
if (
|
|
587
|
-
if (
|
|
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 (
|
|
590
|
-
if (/(^|[^<&])>(?!>|&)/.test(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
|
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
|
|
1109
|
-
|
|
1314
|
+
const analysis = analyzeCommand(raw);
|
|
1315
|
+
|
|
1316
|
+
if (analysis.forbiddenSyntax)
|
|
1110
1317
|
return {
|
|
1111
1318
|
kind: "forbidden-syntax",
|
|
1112
|
-
detail: `forbidden shell syntax: ${
|
|
1113
|
-
suggestion:
|
|
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
|
-
|
|
1119
|
-
|
|
1325
|
+
if (analysis.destructive) {
|
|
1326
|
+
const { kind, label } = analysis.destructive;
|
|
1120
1327
|
return {
|
|
1121
|
-
kind
|
|
1122
|
-
detail:
|
|
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
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
const head =
|
|
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
|
+
}
|