@dieulc/workflow 0.2.0 → 0.3.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/permissions.ts ADDED
@@ -0,0 +1,1042 @@
1
+ /**
2
+ * Workflow Extension — role-based command permissions.
3
+ *
4
+ * One place decides what a role may execute. Plan Mode (planner), Review Mode
5
+ * (reviewer) and explorer subagents (explorer) share this policy; the Builder
6
+ * role — and a normal Pi session with no workflow mode — is unrestricted.
7
+ *
8
+ * Command classes, weakest to strongest:
9
+ * read-only inspection that never executes project code
10
+ * verify read-only plus test/lint/typecheck runners (they execute code
11
+ * but do not modify tracked sources)
12
+ * full unrestricted / unrecognised
13
+ *
14
+ * The module is deliberately dependency-free (no Pi / pi-tui imports) so it
15
+ * stays loadable in plain Node tests, exactly like `utils.ts`.
16
+ */
17
+
18
+ export type CommandClass = "read-only" | "verify" | "full";
19
+
20
+ export type WorkflowRole = "planner" | "reviewer" | "explorer" | "builder";
21
+
22
+ export interface RolePolicy {
23
+ role: WorkflowRole;
24
+ /** Highest command class the role may run. */
25
+ commandClass: CommandClass;
26
+ /** May the role use edit/write? (Plan Mode keeps its .pi/plans/ exception.) */
27
+ canWrite: boolean;
28
+ /** Human-readable summary used in refusal messages. */
29
+ description: string;
30
+ }
31
+
32
+ /** Env var that carries a workflow role into a spawned `pi` subagent process. */
33
+ export const WORKFLOW_ROLE_ENV = "PI_WORKFLOW_ROLE";
34
+
35
+ const WORKFLOW_ROLES: readonly WorkflowRole[] = [
36
+ "planner",
37
+ "reviewer",
38
+ "explorer",
39
+ "builder",
40
+ ];
41
+
42
+ export const ROLE_POLICIES: Record<WorkflowRole, RolePolicy> = {
43
+ planner: {
44
+ role: "planner",
45
+ commandClass: "verify",
46
+ canWrite: false,
47
+ description: "read-only plus test/lint/typecheck commands",
48
+ },
49
+ reviewer: {
50
+ role: "reviewer",
51
+ commandClass: "verify",
52
+ canWrite: false,
53
+ description: "read-only plus test/lint/typecheck commands",
54
+ },
55
+ explorer: {
56
+ role: "explorer",
57
+ commandClass: "read-only",
58
+ canWrite: false,
59
+ description: "read-only commands only",
60
+ },
61
+ builder: {
62
+ role: "builder",
63
+ commandClass: "full",
64
+ canWrite: true,
65
+ description: "unrestricted",
66
+ },
67
+ };
68
+
69
+ // ── Role helpers ─────────────────────────────────────────────────────
70
+
71
+ /** Case/space-tolerant role parser; undefined for anything unknown. */
72
+ export function normalizeRole(value: unknown): WorkflowRole | undefined {
73
+ if (typeof value !== "string") return undefined;
74
+ const v = value.trim().toLowerCase();
75
+ return (WORKFLOW_ROLES as readonly string[]).includes(v)
76
+ ? (v as WorkflowRole)
77
+ : undefined;
78
+ }
79
+
80
+ export function isWorkflowRole(value: unknown): value is WorkflowRole {
81
+ return normalizeRole(value) !== undefined;
82
+ }
83
+
84
+ export function policyForRole(role: WorkflowRole): RolePolicy {
85
+ return ROLE_POLICIES[role] ?? ROLE_POLICIES.explorer;
86
+ }
87
+
88
+ /**
89
+ * Resolve the role a session/tool call should be gated as.
90
+ *
91
+ * Env wins (a spawned subagent carries `PI_WORKFLOW_ROLE`), then the workflow
92
+ * mode: plan → planner, build → builder. No mode and no env → undefined, i.e.
93
+ * a normal Pi session with no extra gating.
94
+ *
95
+ * Call this lazily per tool call, not at extension load: `workflowMode` is
96
+ * `null` while the extension body is evaluated and changes on Tab/mode switch.
97
+ */
98
+ export function resolveSessionRole(input?: {
99
+ envRole?: unknown;
100
+ workflowMode?: string | null;
101
+ }): WorkflowRole | undefined {
102
+ const fromEnv = normalizeRole(input?.envRole);
103
+ if (fromEnv) return fromEnv;
104
+ if (input?.workflowMode === "plan") return "planner";
105
+ if (input?.workflowMode === "build") return "builder";
106
+ return undefined;
107
+ }
108
+
109
+ /** Env for a spawned subagent: carries the role into the child `pi` process. */
110
+ export function subagentEnv(
111
+ baseEnv: NodeJS.ProcessEnv,
112
+ role: WorkflowRole | undefined,
113
+ ): NodeJS.ProcessEnv {
114
+ if (!role) return { ...baseEnv };
115
+ return { ...baseEnv, [WORKFLOW_ROLE_ENV]: role };
116
+ }
117
+
118
+ // ── Command tables ───────────────────────────────────────────────────
119
+
120
+ /** Read-only executables (first word); anything needing context is special-cased. */
121
+ const READ_ONLY_EXECUTABLES = new Set<string>([
122
+ "cat",
123
+ "head",
124
+ "tail",
125
+ "less",
126
+ "more",
127
+ "bat",
128
+ "tac",
129
+ "nl",
130
+ "rev",
131
+ "fold",
132
+ "fmt",
133
+ "column",
134
+ "cut",
135
+ "tr",
136
+ "wc",
137
+ "uniq",
138
+ "diff",
139
+ "cmp",
140
+ "comm",
141
+ "join",
142
+ "paste",
143
+ "grep",
144
+ "ag",
145
+ "ack",
146
+ "fd",
147
+ "ls",
148
+ "pwd",
149
+ "tree",
150
+ "eza",
151
+ "du",
152
+ "df",
153
+ "stat",
154
+ "file",
155
+ "realpath",
156
+ "basename",
157
+ "dirname",
158
+ "readlink",
159
+ "which",
160
+ "whereis",
161
+ "type",
162
+ "echo",
163
+ "printf",
164
+ "seq",
165
+ "test",
166
+ "[",
167
+ "uname",
168
+ "whoami",
169
+ "id",
170
+ "groups",
171
+ "hostname",
172
+ "date",
173
+ "cal",
174
+ "uptime",
175
+ "ps",
176
+ "top",
177
+ "htop",
178
+ "free",
179
+ "md5sum",
180
+ "sha1sum",
181
+ "sha256sum",
182
+ "sha512sum",
183
+ "xxd",
184
+ "hexdump",
185
+ "strings",
186
+ "cksum",
187
+ "journalctl",
188
+ "base64",
189
+ "man",
190
+ "ss",
191
+ "netstat",
192
+ "lsof",
193
+ ]);
194
+
195
+ /** Bare interpreters are never allowed for restricted roles (fail closed). */
196
+ const INTERPRETER_EXECUTABLES = new Set<string>([
197
+ "sh",
198
+ "bash",
199
+ "dash",
200
+ "zsh",
201
+ "fish",
202
+ "ksh",
203
+ "csh",
204
+ "tcsh",
205
+ "pwsh",
206
+ "powershell",
207
+ "cmd",
208
+ "cscript",
209
+ "wscript",
210
+ "perl",
211
+ "ruby",
212
+ ]);
213
+
214
+ /** Test runners that may be told to rewrite snapshots with `-u`/`-w`. */
215
+ const TEST_RUNNERS = new Set<string>([
216
+ "vitest",
217
+ "jest",
218
+ "mocha",
219
+ "ava",
220
+ "pytest",
221
+ ]);
222
+
223
+ const NPM_READ_ONLY_SUBCOMMANDS = new Set<string>([
224
+ "list",
225
+ "ls",
226
+ "view",
227
+ "info",
228
+ "show",
229
+ "search",
230
+ "outdated",
231
+ "audit",
232
+ "why",
233
+ "explain",
234
+ "doctor",
235
+ "ping",
236
+ "fund",
237
+ "repo",
238
+ "bugs",
239
+ "root",
240
+ "prefix",
241
+ ]);
242
+
243
+ const YARN_READ_ONLY_SUBCOMMANDS = new Set<string>([
244
+ "list",
245
+ "info",
246
+ "why",
247
+ "audit",
248
+ "versions",
249
+ "outdated",
250
+ ]);
251
+
252
+ const PNPM_READ_ONLY_SUBCOMMANDS = new Set<string>([
253
+ "list",
254
+ "ls",
255
+ "why",
256
+ "outdated",
257
+ "audit",
258
+ ]);
259
+
260
+ const GIT_READ_ONLY_SUBCOMMANDS = new Set<string>([
261
+ "status",
262
+ "log",
263
+ "diff",
264
+ "show",
265
+ "blame",
266
+ "shortlog",
267
+ "rev-parse",
268
+ "rev-list",
269
+ "describe",
270
+ "for-each-ref",
271
+ "reflog",
272
+ "cat-file",
273
+ "merge-base",
274
+ "check-ignore",
275
+ "check-attr",
276
+ "count-objects",
277
+ "name-rev",
278
+ "whatchanged",
279
+ "show-branch",
280
+ "verify-commit",
281
+ "verify-tag",
282
+ "grep",
283
+ "help",
284
+ "version",
285
+ "ls-files",
286
+ "ls-tree",
287
+ "ls-remote",
288
+ "var",
289
+ "show-ref",
290
+ "check-ref-format",
291
+ "diff-tree",
292
+ ]);
293
+
294
+ const GIT_BRANCH_BLOCKED_FLAGS = new Set<string>([
295
+ "-d",
296
+ "-D",
297
+ "-m",
298
+ "-M",
299
+ "-c",
300
+ "-C",
301
+ "-u",
302
+ "--delete",
303
+ "--move",
304
+ "--copy",
305
+ "--edit-description",
306
+ "--set-upstream-to",
307
+ "--unset-upstream",
308
+ ]);
309
+
310
+ const GIT_REMOTE_MUTATIONS = new Set<string>([
311
+ "add",
312
+ "remove",
313
+ "rm",
314
+ "set-url",
315
+ "set-branches",
316
+ "set-head",
317
+ "rename",
318
+ "prune",
319
+ "update",
320
+ ]);
321
+
322
+ const GIT_CONFIG_READ_FLAGS = new Set<string>([
323
+ "--get",
324
+ "--get-all",
325
+ "--get-regexp",
326
+ "--get-urlmatch",
327
+ "--list",
328
+ "-l",
329
+ ]);
330
+
331
+ const GIT_CONFIG_WRITE_FLAGS = new Set<string>([
332
+ "--add",
333
+ "--unset",
334
+ "--unset-all",
335
+ "--replace-all",
336
+ "--edit",
337
+ "-e",
338
+ "--rename-section",
339
+ "--remove-section",
340
+ ]);
341
+
342
+ const DOCKER_READ_ONLY_SUBCOMMANDS = new Set<string>([
343
+ "ps",
344
+ "images",
345
+ "inspect",
346
+ "logs",
347
+ "version",
348
+ "info",
349
+ "history",
350
+ "port",
351
+ "diff",
352
+ ]);
353
+
354
+ const SYSTEMCTL_READ_ONLY_SUBCOMMANDS = new Set<string>([
355
+ "status",
356
+ "show",
357
+ "list-units",
358
+ "list-unit-files",
359
+ "is-active",
360
+ "is-enabled",
361
+ "is-failed",
362
+ "cat",
363
+ "help",
364
+ ]);
365
+
366
+ const ALLOWED_SCRIPT_RE =
367
+ /^(test|test[:._-].*|lint|lint[:._-].*|typecheck|type-check|tsc|check|check[:._-].*|format:check|format-check)$/i;
368
+
369
+ const BLOCKED_SCRIPT_SUFFIX_RE = /[:._-](fix|write)([:._-]|$)/i;
370
+
371
+ // ── Dangerous syntax / patterns ──────────────────────────────────────
372
+
373
+ /**
374
+ * Hard denylist. Applied to the whole raw command (quote-insensitive, like the
375
+ * original implementation) before any allowlist classification.
376
+ *
377
+ * The git entries are deliberately narrowed so the read-only forms the
378
+ * allowlist exposes are actually reachable: `git tag -l` / `--list`,
379
+ * `git stash list|show`, and `git config --get*|--list` must NOT match here.
380
+ */
381
+ const DESTRUCTIVE_PATTERNS: RegExp[] = [
382
+ /\brm\b/i,
383
+ /\brmdir\b/i,
384
+ /\bmv\b/i,
385
+ /\bcp\b/i,
386
+ /\bmkdir\b/i,
387
+ /\btouch\b/i,
388
+ /\bchmod\b/i,
389
+ /\bchown\b/i,
390
+ /\bchgrp\b/i,
391
+ /\bln\b/i,
392
+ /\btee\b/i,
393
+ /\btruncate\b/i,
394
+ /\bdd\b/i,
395
+ /\bshred\b/i,
396
+ /\bnpm\s+(install|uninstall|update|ci|link|publish|init|create|dedupe|prune|rebuild|pack)\b/i,
397
+ /\byarn\s+(add|remove|install|publish|init|create|upgrade|dedupe|prune)\b/i,
398
+ /\bpnpm\s+(add|remove|install|publish|init|create|upgrade|dedupe|prune)\b/i,
399
+ /\bbun\s+(add|remove|install|publish|init|create|upgrade|link)\b/i,
400
+ /\bpip[23]?\s+(install|uninstall|download|wheel)\b/i,
401
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)\b/i,
402
+ /\bbrew\s+(install|uninstall|upgrade|reinstall)\b/i,
403
+ /\bgit\s+(add|commit|push|pull|fetch|merge|rebase|reset|checkout|switch|restore|clean|gc|prune|cherry-pick|revert|init|clone|apply|am|update-ref|update-index|symbolic-ref|filter-branch|replace)\b/i,
404
+ /\bgit\s+branch\s+(-[dDmMcC]|--delete|--move|--copy)\b/i,
405
+ /\bgit\s+stash(?!\s+(?:list|show)\b)(?:\s|$)/i,
406
+ /\bgit\s+tag\s+(?!--list\b|-l\b)\S+/i,
407
+ /\bgit\s+remote\s+(add|remove|rm|set-url|set-branches|set-head|rename|prune|update)\b/i,
408
+ /\bgit\s+worktree\s+(add|remove|prune|move|lock|unlock|repair)\b/i,
409
+ /\bgit\s+reflog\s+(expire|delete)\b/i,
410
+ /\bgit\s+submodule\s+(add|update|deinit|set-url|sync|absorbgitdirs)\b/i,
411
+ /\bgit\s+notes\s+(add|remove|copy|append|edit|prune)\b/i,
412
+ /\bsudo\b/i,
413
+ /\bsu\b/i,
414
+ /\bkill\b/i,
415
+ /\bpkill\b/i,
416
+ /\bkillall\b/i,
417
+ /\breboot\b/i,
418
+ /\bshutdown\b/i,
419
+ /\bsystemctl\s+(start|stop|restart|enable|disable|mask|unmask|reload)\b/i,
420
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
421
+ /\b(vim?|nano|emacs|code|subl)\b/i,
422
+ /\bfind\b[^|;&]*\s-(delete|exec|execdir|ok|okdir|fprint\w*|fls)\b/i,
423
+ /\bawk\b[^|;&]*\bsystem\s*\(/i,
424
+ /(^|\s)(sh|bash|dash|zsh|fish|ksh|csh|tcsh|pwsh|powershell|cmd|cscript|wscript|perl|ruby)(\.exe)?(\s|$)/i,
425
+ ];
426
+
427
+ /** Writing/formatting flags that must never reach a verify-class command. */
428
+ const DENIED_FLAG_RE =
429
+ /(^|\s)(--fix|--fix-dry-run|--write|--update|--update-snapshots?|--updateSnapshot|--test-update-snapshots|--watch|--watchAll|--inspect|--inspect-brk|--in-place|--output)(\s|$|=)/i;
430
+
431
+ /**
432
+ * Snapshot/update and watch flags. Applied to bare test runners and to
433
+ * test-shaped package-manager invocations (`npm test -- -u`,
434
+ * `node --test --test-update-snapshots`, `go test -update`), which is where a
435
+ * verify-class command can still rewrite tracked files.
436
+ */
437
+ const TEST_UPDATE_FLAG_RE =
438
+ /(^|\s)(-u|-w|-update|--update|--test-update-snapshots)(\s|$)/;
439
+
440
+ /** Flags that take an arbitrary output path — never allowed for verify tools. */
441
+ const ARBITRARY_OUTPUT_FLAG_RE =
442
+ /(^|\s)(--output-file|--outputFile|--output-dir)(\s|$|=)/;
443
+
444
+ /** pytest flags that write to a chosen path or clear caches. */
445
+ const PYTEST_OUTPUT_FLAG_RE =
446
+ /(^|\s)(--junit-xml|--junitxml|--cache-clear)(\s|$|=)/;
447
+
448
+ /** `go test`/`go vet` flags that execute a program or write a chosen path. */
449
+ const GO_EXEC_FLAG_RE =
450
+ /(^|\s)(-exec|--exec|-vettool|-o|-coverprofile|-cpuprofile|-memprofile|-blockprofile|-mutexprofile|-trace)(\s|$|=)/;
451
+
452
+ /** mypy/ruff flags that install packages or rewrite sources. */
453
+ const MYPY_DENY_FLAG_RE = /(^|\s)--install-types(\s|$|=)/;
454
+ const RUFF_NOQA_FLAG_RE = /(^|\s)--add-noqa(\s|$|=)/;
455
+
456
+ const SORT_OUTPUT_RE = /(^|\s)-o(\s|=|$)/;
457
+ const IN_PLACE_RE = /(^|\s)(-i|--in-place)(\s|$)/;
458
+ const RG_PRE_RE = /(^|\s)--pre(=|\s)/;
459
+ const FIND_DENIED_FLAG_RE =
460
+ /(^|\s)-(delete|exec|execdir|ok|okdir|fprint\w*|fls)(\s|$)/;
461
+ const CURL_WRITE_RE =
462
+ /(^|\s)(-o|-O|--output|--output-dir|--remote-name|--remote-header-name|-d|--data[^\s]*|-F|--form[^\s]*|--json|-T|--upload-file)(\s|=|$)/;
463
+ const CURL_METHOD_RE =
464
+ /(^|\s)(-X\s*|--request[=\s]+)(POST|PUT|DELETE|PATCH|CONNECT)\b/i;
465
+
466
+ // ── Token helpers ────────────────────────────────────────────────────
467
+
468
+ function wordsOf(segment: string): string[] {
469
+ return segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
470
+ }
471
+
472
+ function wordAt(segment: string, index: number): string | undefined {
473
+ return wordsOf(segment)[index];
474
+ }
475
+
476
+ function unquote(token: string): string {
477
+ return token.trim().replace(/^["']|["']$/g, "");
478
+ }
479
+
480
+ /** Normalise an executable token: drop dirs, quotes, and a trailing .exe/.cmd. */
481
+ function normalizeBinary(token: string): string {
482
+ let t = unquote(token);
483
+ const slash = Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\"));
484
+ if (slash >= 0) t = t.slice(slash + 1);
485
+ t = t.toLowerCase();
486
+ for (const ext of [".exe", ".cmd", ".bat", ".ps1"]) {
487
+ if (t.endsWith(ext)) {
488
+ t = t.slice(0, -ext.length);
489
+ break;
490
+ }
491
+ }
492
+ return t;
493
+ }
494
+
495
+ function firstWord(segment: string): string {
496
+ return normalizeBinary(wordAt(segment, 0) ?? "");
497
+ }
498
+
499
+ /** Token-exact flag test (also matches `--flag=value`); no dynamic RegExp. */
500
+ function containsFlag(segment: string, flag: string): boolean {
501
+ return wordsOf(segment).some(
502
+ (w) => w === flag || w.startsWith(`${flag}=`),
503
+ );
504
+ }
505
+
506
+ function isAllowedScript(rawScript: string | undefined): boolean {
507
+ const script = unquote(String(rawScript ?? ""));
508
+ if (!script) return false;
509
+ if (!ALLOWED_SCRIPT_RE.test(script)) return false;
510
+ if (BLOCKED_SCRIPT_SUFFIX_RE.test(script)) return false;
511
+ return true;
512
+ }
513
+
514
+ /**
515
+ * True when a tool is being told where to write (`eslint -o out`, jest's
516
+ * `--outputFile=x`). Deliberately not global: `grep -o` / `rg -o` mean
517
+ * "only matching" and are read-only flags.
518
+ */
519
+ function hasArbitraryOutputFlag(segment: string): boolean {
520
+ if (ARBITRARY_OUTPUT_FLAG_RE.test(segment)) return true;
521
+ return wordsOf(segment).some(
522
+ (w) => w === "-o" || (w.startsWith("-o") && w.length > 2 && !w.startsWith("--")),
523
+ );
524
+ }
525
+
526
+ /**
527
+ * The script argument of `npm run …` / `yarn run …` (index 2 onward), skipping
528
+ * flags and stopping at a `--` separator. `undefined` means no script was
529
+ * named, which is the read-only "list scripts" form.
530
+ */
531
+ function packageScriptArg(segment: string): string | undefined {
532
+ const words = wordsOf(segment).slice(2);
533
+ const sep = words.indexOf("--");
534
+ const candidates = sep >= 0 ? words.slice(0, sep) : words;
535
+ return candidates.find((w) => !w.startsWith("-"));
536
+ }
537
+
538
+ // ── Shell syntax scanner ─────────────────────────────────────────────
539
+
540
+ /**
541
+ * A redirection whose target is the null device: an optional fd or `&`,
542
+ * followed by `>` / `>>` and exactly `/dev/null`. Discarding output is not a
543
+ * file write, so these are subtracted before the file-redirect check. The
544
+ * lookahead keeps the match to a complete token — `/dev/null.txt`,
545
+ * `/dev/nullx` and `/dev/null/../f` are real files and must not be scrubbed.
546
+ */
547
+ const NULL_DEVICE_REDIRECT_RE =
548
+ /(^|[\s;&|()])(?:[12]|&)?>>?\s*\/dev\/null(?=$|[\s;&|)])/g;
549
+
550
+ /**
551
+ * Why a command's syntax cannot be classified (`null` = the syntax is fine).
552
+ * Mirrors the order the gate has always used, so a refusal can name the first
553
+ * failing construct instead of only saying "blocked".
554
+ */
555
+ export function forbiddenSyntaxReason(command: unknown): string | null {
556
+ const raw = typeof command === "string" ? command : "";
557
+ // `2>&1` / `1>&2` are fd duplications, not file writes; `/dev/null` targets
558
+ // are a bit bucket, not a file.
559
+ const scrubbed = raw
560
+ .replace(/\d*>&\d+/g, " ")
561
+ .replace(NULL_DEVICE_REDIRECT_RE, "$1");
562
+ if (scrubbed.replace(/&&/g, " ").includes("&"))
563
+ return "a background `&` (only `&&` chains are allowed)";
564
+ if (raw.includes("$(")) return "command substitution `$(...)`";
565
+ if (raw.includes("`")) return "backtick command substitution";
566
+ if (raw.includes("${")) return "parameter expansion `${...}`";
567
+ if (raw.includes("<(") || raw.includes(">("))
568
+ return "process substitution `<(...)` / `>(...)`";
569
+ if (raw.includes("<<")) return "a heredoc `<<`";
570
+ if (/(^|[^<&])>(?!>|&)/.test(scrubbed))
571
+ return "a file redirect (`>` / `>>`); only `/dev/null` and `2>&1` are allowed";
572
+ return null;
573
+ }
574
+
575
+ /**
576
+ * Reject syntax a prefix allowlist cannot reason about: command substitution,
577
+ * process substitution, heredocs, background `&` and file redirection.
578
+ * Redirections to `/dev/null` and fd duplications (`2>&1` / `1>&2`) are
579
+ * allowed: neither writes a file the user can see.
580
+ */
581
+ export function hasForbiddenSyntax(command: string): boolean {
582
+ return forbiddenSyntaxReason(command) !== null;
583
+ }
584
+
585
+ /**
586
+ * Split a compound command on top-level `|`, `;`, `&&`, `||` and newlines.
587
+ * Quote-aware: `grep -E 'a|b' f` stays one segment.
588
+ */
589
+ export function splitCommandSegments(command: string): string[] {
590
+ const segments: string[] = [];
591
+ let current = "";
592
+ let quote: '"' | "'" | undefined;
593
+ let escaped = false;
594
+ const push = (): void => {
595
+ const s = current.trim();
596
+ if (s) segments.push(s);
597
+ current = "";
598
+ };
599
+ for (let i = 0; i < command.length; i++) {
600
+ const ch = command[i];
601
+ if (escaped) {
602
+ current += ch;
603
+ escaped = false;
604
+ continue;
605
+ }
606
+ if (ch === "\\") {
607
+ current += ch;
608
+ escaped = true;
609
+ continue;
610
+ }
611
+ if (quote) {
612
+ current += ch;
613
+ if (ch === quote) quote = undefined;
614
+ continue;
615
+ }
616
+ if (ch === '"' || ch === "'") {
617
+ quote = ch;
618
+ current += ch;
619
+ continue;
620
+ }
621
+ if (ch === "\n" || ch === ";") {
622
+ push();
623
+ continue;
624
+ }
625
+ if (ch === "|") {
626
+ push();
627
+ if (command[i + 1] === "|") i++;
628
+ continue;
629
+ }
630
+ if (ch === "&" && command[i + 1] === "&") {
631
+ push();
632
+ i++;
633
+ continue;
634
+ }
635
+ current += ch;
636
+ }
637
+ push();
638
+ return segments;
639
+ }
640
+
641
+ // ── Classification ───────────────────────────────────────────────────
642
+
643
+ /** Classify one already-split segment. Unknown input → `full` (fail closed). */
644
+ export function commandClassOf(segment: string): CommandClass {
645
+ const s = String(segment ?? "").trim();
646
+ if (!s) return "full";
647
+ const first = firstWord(s);
648
+
649
+ if (!first) return "full";
650
+ if (INTERPRETER_EXECUTABLES.has(first)) return "full";
651
+
652
+ if (first === "cd") {
653
+ const args = wordsOf(s).slice(1);
654
+ return args.length <= 1 && !args.some((a) => a.startsWith("-"))
655
+ ? "read-only"
656
+ : "full";
657
+ }
658
+ if (first === "npm" || first === "yarn" || first === "pnpm" || first === "bun")
659
+ return classifyPackageManager(first, s);
660
+ if (first === "git") return classifyGit(s);
661
+ if (first === "docker") return classifyDocker(s);
662
+ if (first === "systemctl") return classifySystemctl(s);
663
+ if (first === "journalctl") return "read-only";
664
+ if (first === "find")
665
+ return FIND_DENIED_FLAG_RE.test(s) ? "full" : "read-only";
666
+ if (first === "env") return isBareEnv(s) ? "read-only" : "full";
667
+ if (first === "printenv") return "read-only";
668
+ if (first === "node") return classifyNode(s);
669
+ if (first === "python" || first === "python3" || first === "py")
670
+ return classifyPython(s);
671
+ if (first === "pip" || first === "pip3") return classifyPip(s);
672
+ if (first === "go") return classifyGo(s);
673
+ if (first === "cargo") return classifyCargo(s);
674
+ if (first === "rustc")
675
+ return containsFlag(s, "--version") || containsFlag(s, "-V")
676
+ ? "read-only"
677
+ : "full";
678
+ if (first === "tsc") return containsFlag(s, "--noEmit") ? "verify" : "full";
679
+ if (first === "prettier")
680
+ return containsFlag(s, "--check") ? "verify" : "full";
681
+ if (first === "black")
682
+ return containsFlag(s, "--check") || containsFlag(s, "--diff")
683
+ ? "verify"
684
+ : "full";
685
+ if (first === "gofmt")
686
+ return containsFlag(s, "-l") || containsFlag(s, "-d") ? "verify" : "full";
687
+ if (first === "ruff")
688
+ return wordAt(s, 1)?.toLowerCase() === "check" &&
689
+ !RUFF_NOQA_FLAG_RE.test(s)
690
+ ? "verify"
691
+ : "full";
692
+ if (first === "eslint")
693
+ return hasArbitraryOutputFlag(s) ? "full" : "verify";
694
+ if (TEST_RUNNERS.has(first)) {
695
+ if (TEST_UPDATE_FLAG_RE.test(s) || ARBITRARY_OUTPUT_FLAG_RE.test(s))
696
+ return "full";
697
+ if (first === "pytest" && PYTEST_OUTPUT_FLAG_RE.test(s)) return "full";
698
+ return "verify";
699
+ }
700
+ if (first === "mypy") return MYPY_DENY_FLAG_RE.test(s) ? "full" : "verify";
701
+ if (first === "flake8") return "verify";
702
+ if (first === "sort") return SORT_OUTPUT_RE.test(s) ? "full" : "read-only";
703
+ if (first === "jq" || first === "yq")
704
+ return IN_PLACE_RE.test(s) ? "full" : "read-only";
705
+ if (first === "rg") return RG_PRE_RE.test(s) ? "full" : "read-only";
706
+ if (first === "wget")
707
+ return wordAt(s, 1) === "-O" && wordAt(s, 2) === "-" ? "read-only" : "full";
708
+ // `sed`/`awk` are deliberately NOT read-only: their programs are code (GNU
709
+ // sed's `e` command runs a shell command; awk can pipe to a command, write
710
+ // files or load a library). They fall through to `full`, so only Builder
711
+ // keeps them. Use rg/head/tail/cut/column/jq instead.
712
+ if (first === "curl")
713
+ return CURL_WRITE_RE.test(s) || CURL_METHOD_RE.test(s)
714
+ ? "full"
715
+ : "read-only";
716
+ if (READ_ONLY_EXECUTABLES.has(first)) return "read-only";
717
+ return "full";
718
+ }
719
+
720
+ function classifyPackageManager(bin: string, s: string): CommandClass {
721
+ const sub = wordAt(s, 1)?.toLowerCase();
722
+ if (!sub) return "full";
723
+ const script = packageScriptArg(s);
724
+ // `npm test -- -u` and `npm run test -- -u` forward the snapshot-update flag
725
+ // to the runner; the bare runner would be refused, so refuse the wrapper too.
726
+ const testShaped =
727
+ sub === "test" || (sub === "run" && isAllowedScript(script));
728
+ if (testShaped && TEST_UPDATE_FLAG_RE.test(s)) return "full";
729
+ if (sub === "test") return "verify";
730
+ if (sub === "run") {
731
+ if (isAllowedScript(script)) return "verify";
732
+ // `npm run` with no script argument lists the scripts — a read.
733
+ if (script === undefined) return "read-only";
734
+ }
735
+ if (
736
+ sub === "config" &&
737
+ ["get", "list"].includes(wordAt(s, 2)?.toLowerCase() ?? "")
738
+ )
739
+ return "read-only";
740
+ if (bin === "npm") {
741
+ if (sub === "pkg" && wordAt(s, 2)?.toLowerCase() === "get")
742
+ return "read-only";
743
+ return NPM_READ_ONLY_SUBCOMMANDS.has(sub) ? "read-only" : "full";
744
+ }
745
+ if (bin === "yarn")
746
+ return YARN_READ_ONLY_SUBCOMMANDS.has(sub) ? "read-only" : "full";
747
+ if (bin === "pnpm") {
748
+ if (
749
+ sub === "licenses" &&
750
+ wordAt(s, 2)?.toLowerCase() === "list"
751
+ )
752
+ return "read-only";
753
+ return PNPM_READ_ONLY_SUBCOMMANDS.has(sub) ? "read-only" : "full";
754
+ }
755
+ // bun
756
+ if (sub === "pm" && wordAt(s, 2)?.toLowerCase() === "ls")
757
+ return "read-only";
758
+ return "full";
759
+ }
760
+
761
+ function classifyGit(s: string): CommandClass {
762
+ const words = wordsOf(s);
763
+ let i = 1;
764
+ while (i < words.length) {
765
+ const w = words[i];
766
+ if (w === "-C") {
767
+ i += 2;
768
+ continue;
769
+ }
770
+ // `-c <key>=<value>` sets config for one invocation, and config values can
771
+ // select programs git executes (core.fsmonitor, core.pager,
772
+ // credential.helper, diff.external). Restricted roles must not reach it.
773
+ if (w === "-c") return "full";
774
+ if (
775
+ w === "--no-pager" ||
776
+ w === "--no-optional-locks" ||
777
+ w === "--literal-pathspecs"
778
+ ) {
779
+ i += 1;
780
+ continue;
781
+ }
782
+ break;
783
+ }
784
+ const sub = (words[i] ?? "").toLowerCase();
785
+ if (!sub) return "full";
786
+ const rest = words.slice(i + 1);
787
+
788
+ if (sub === "branch") {
789
+ const blocked = rest.some(
790
+ (w) =>
791
+ GIT_BRANCH_BLOCKED_FLAGS.has(w) ||
792
+ w.startsWith("--set-upstream-to="),
793
+ );
794
+ return blocked ? "full" : "read-only";
795
+ }
796
+ if (sub === "remote")
797
+ return rest.some((w) => GIT_REMOTE_MUTATIONS.has(w.toLowerCase()))
798
+ ? "full"
799
+ : "read-only";
800
+ if (sub === "config") {
801
+ const flags = rest.filter((w) => w.startsWith("-"));
802
+ const hasRead = flags.some(
803
+ (f) =>
804
+ GIT_CONFIG_READ_FLAGS.has(f) ||
805
+ f.startsWith("--get=") ||
806
+ f.startsWith("--get-regexp="),
807
+ );
808
+ const hasWrite = flags.some((f) => GIT_CONFIG_WRITE_FLAGS.has(f));
809
+ return hasRead && !hasWrite ? "read-only" : "full";
810
+ }
811
+ if (sub === "tag")
812
+ return rest.length === 0 ||
813
+ rest.some((w) => w === "-l" || w === "--list")
814
+ ? "read-only"
815
+ : "full";
816
+ if (sub === "stash") {
817
+ const a = (rest[0] ?? "").toLowerCase();
818
+ return a === "list" || a === "show" ? "read-only" : "full";
819
+ }
820
+ if (sub === "worktree") {
821
+ const a = (rest[0] ?? "").toLowerCase();
822
+ return a === "list" ? "read-only" : "full";
823
+ }
824
+ return GIT_READ_ONLY_SUBCOMMANDS.has(sub) ? "read-only" : "full";
825
+ }
826
+
827
+ function classifyDocker(s: string): CommandClass {
828
+ const sub = wordAt(s, 1)?.toLowerCase();
829
+ if (!sub) return "full";
830
+ if (sub === "compose") {
831
+ const a = wordAt(s, 2)?.toLowerCase();
832
+ return ["ps", "logs", "config", "images", "version"].includes(a ?? "")
833
+ ? "read-only"
834
+ : "full";
835
+ }
836
+ return DOCKER_READ_ONLY_SUBCOMMANDS.has(sub) ? "read-only" : "full";
837
+ }
838
+
839
+ function classifySystemctl(s: string): CommandClass {
840
+ const sub = wordAt(s, 1)?.toLowerCase();
841
+ return SYSTEMCTL_READ_ONLY_SUBCOMMANDS.has(sub ?? "")
842
+ ? "read-only"
843
+ : "full";
844
+ }
845
+
846
+ function isBareEnv(s: string): boolean {
847
+ const rest = wordsOf(s).slice(1);
848
+ if (rest.length === 0) return true;
849
+ return rest.every((w) => /^-{1,2}[A-Za-z0-9-]+$/.test(w));
850
+ }
851
+
852
+ function classifyNode(s: string): CommandClass {
853
+ const a = wordAt(s, 1)?.toLowerCase();
854
+ if (!a) return "full";
855
+ if (a === "--version" || a === "-v") return "read-only";
856
+ if (a === "--test" || a.startsWith("--test-"))
857
+ return TEST_UPDATE_FLAG_RE.test(s) ? "full" : "verify";
858
+ if (a === "--check") return "verify";
859
+ // `node --run <script>` executes a package script: same gate as `npm run`.
860
+ if (a === "--run") {
861
+ if (TEST_UPDATE_FLAG_RE.test(s)) return "full";
862
+ return isAllowedScript(wordAt(s, 2)) ? "verify" : "full";
863
+ }
864
+ return "full";
865
+ }
866
+
867
+ function classifyPython(s: string): CommandClass {
868
+ const a = wordAt(s, 1)?.toLowerCase();
869
+ if (!a) return "full";
870
+ if (a === "--version" || a === "-v" || a === "-V") return "read-only";
871
+ if (a === "-m") {
872
+ const mod = wordAt(s, 2)?.toLowerCase();
873
+ if (mod === "pytest" || mod === "unittest")
874
+ return PYTEST_OUTPUT_FLAG_RE.test(s) ? "full" : "verify";
875
+ if (mod === "mypy") return MYPY_DENY_FLAG_RE.test(s) ? "full" : "verify";
876
+ if (mod === "flake8") return "verify";
877
+ if (
878
+ mod === "black" &&
879
+ (containsFlag(s, "--check") || containsFlag(s, "--diff"))
880
+ )
881
+ return "verify";
882
+ if (mod === "ruff" && wordAt(s, 3)?.toLowerCase() === "check")
883
+ return "verify";
884
+ if (
885
+ mod === "pip" &&
886
+ ["list", "show", "freeze", "check"].includes(
887
+ wordAt(s, 3)?.toLowerCase() ?? "",
888
+ )
889
+ )
890
+ return "read-only";
891
+ }
892
+ return "full";
893
+ }
894
+
895
+ function classifyPip(s: string): CommandClass {
896
+ const sub = wordAt(s, 1)?.toLowerCase();
897
+ if (sub === "--version" || sub === "-V") return "read-only";
898
+ if (sub && ["list", "show", "freeze", "check"].includes(sub))
899
+ return "read-only";
900
+ return "full";
901
+ }
902
+
903
+ function classifyGo(s: string): CommandClass {
904
+ const sub = wordAt(s, 1)?.toLowerCase();
905
+ if (sub === "version") return "read-only";
906
+ if (sub === "env")
907
+ return /(^|\s)-[wu](\s|$)/.test(s) ? "full" : "read-only";
908
+ if (sub === "test" || sub === "vet")
909
+ return GO_EXEC_FLAG_RE.test(s) ? "full" : "verify";
910
+ return "full";
911
+ }
912
+
913
+ function classifyCargo(s: string): CommandClass {
914
+ const sub = wordAt(s, 1)?.toLowerCase();
915
+ if (sub === "--version" || sub === "-V") return "read-only";
916
+ if (sub === "test" || sub === "check" || sub === "clippy") return "verify";
917
+ if (sub === "fmt") return containsFlag(s, "--check") ? "verify" : "full";
918
+ if (sub === "metadata" || sub === "tree" || sub === "locate-project")
919
+ return "read-only";
920
+ return "full";
921
+ }
922
+
923
+ // ── Public gate ──────────────────────────────────────────────────────
924
+
925
+ /**
926
+ * The first destructive pattern (or writing flag) that matches, with the exact
927
+ * text that matched so a refusal can name it (`rm`, `npm install`, `--fix`).
928
+ */
929
+ function destructiveMatch(
930
+ command: string,
931
+ ): { kind: "destructive-pattern" | "blocked-flag"; label: string } | undefined {
932
+ const hit = DESTRUCTIVE_PATTERNS.find((p) => p.test(command));
933
+ if (hit) {
934
+ const matched = command.match(hit)?.[0]?.trim() ?? "destructive keyword";
935
+ return { kind: "destructive-pattern", label: `\`${matched}\`` };
936
+ }
937
+ const flag = command.match(DENIED_FLAG_RE);
938
+ if (flag)
939
+ return {
940
+ kind: "blocked-flag",
941
+ label: `the flag \`${flag[2] ?? flag[0].trim()}\``,
942
+ };
943
+ return undefined;
944
+ }
945
+
946
+ /** True when a segment is denied outright (destructive syntax or writing flags). */
947
+ export function isDestructiveCommand(command: string): boolean {
948
+ return destructiveMatch(command) !== undefined;
949
+ }
950
+
951
+ function classAllows(policyClass: CommandClass, needed: CommandClass): boolean {
952
+ if (policyClass === "full") return true;
953
+ if (policyClass === "verify") return needed === "read-only" || needed === "verify";
954
+ return needed === "read-only";
955
+ }
956
+
957
+ /** Structured explanation of the first policy violation. */
958
+ export interface CommandRefusal {
959
+ kind:
960
+ | "empty"
961
+ | "forbidden-syntax"
962
+ | "destructive-pattern"
963
+ | "blocked-flag"
964
+ | "segment-class";
965
+ /** One-line, user-facing cause. */
966
+ detail: string;
967
+ /** The offending segment, when the failure is segment-scoped. */
968
+ segment?: string;
969
+ /** What to do instead. */
970
+ suggestion?: string;
971
+ }
972
+
973
+ /**
974
+ * The single source of truth for the gate: `null` means allowed. Keeping the
975
+ * decision and the explanation in one function stops a refusal message from
976
+ * describing a policy the gate no longer enforces.
977
+ */
978
+ export function explainCommandRefusal(
979
+ role: WorkflowRole,
980
+ command: unknown,
981
+ ): CommandRefusal | null {
982
+ const policy = policyForRole(role);
983
+ const raw = typeof command === "string" ? command.trim() : "";
984
+ if (!raw)
985
+ return policy.commandClass === "full"
986
+ ? null
987
+ : { kind: "empty", detail: "the command is empty" };
988
+ if (policy.commandClass === "full") return null;
989
+
990
+ const syntax = forbiddenSyntaxReason(raw);
991
+ if (syntax)
992
+ return {
993
+ kind: "forbidden-syntax",
994
+ detail: `forbidden shell syntax: ${syntax}`,
995
+ suggestion: syntax.includes("redirect")
996
+ ? "drop the redirect — `cmd` already captures stdout and `cmd 2>&1` also captures stderr; only `2>/dev/null` and `2>&1` are exempt"
997
+ : "use an allowlisted read-only/verify command, or /build for full shell access",
998
+ };
999
+
1000
+ const destructive = destructiveMatch(raw);
1001
+ if (destructive)
1002
+ return {
1003
+ kind: destructive.kind,
1004
+ detail: `${destructive.label} matched the raw command text (quoting does not exempt it)`,
1005
+ suggestion:
1006
+ "remove or rename the argument, or use /build for unrestricted access",
1007
+ };
1008
+
1009
+ const segments = splitCommandSegments(raw);
1010
+ if (segments.length === 0)
1011
+ return { kind: "empty", detail: "no command segments found" };
1012
+ for (const segment of segments) {
1013
+ const needed = commandClassOf(segment);
1014
+ if (classAllows(policy.commandClass, needed)) continue;
1015
+ const head = firstWord(segment) || segment.slice(0, 40);
1016
+ return {
1017
+ kind: "segment-class",
1018
+ detail: `\`${head}\` needs ${needed === "full" ? "unrestricted" : needed} access, above the ${policy.commandClass} limit`,
1019
+ segment,
1020
+ suggestion: `${policy.role} may run ${policy.description}; see README "Permissions" for allowlisted alternatives, or use /build`,
1021
+ };
1022
+ }
1023
+ return null;
1024
+ }
1025
+
1026
+ /**
1027
+ * The single gate. `builder` (and callers with no role) is unrestricted; every
1028
+ * other role must pass the destructive denylist and have every top-level
1029
+ * segment classified inside its allowed classes. Fails closed.
1030
+ */
1031
+ export function isCommandAllowedForRole(
1032
+ role: WorkflowRole,
1033
+ command: unknown,
1034
+ ): boolean {
1035
+ return explainCommandRefusal(role, command) === null;
1036
+ }
1037
+
1038
+ /** One-line policy summary for refusal messages. */
1039
+ export function describeRolePolicy(role: WorkflowRole): string {
1040
+ const policy = policyForRole(role);
1041
+ return `${policy.role} may run ${policy.description}`;
1042
+ }