@monte3l/groundwork 0.1.0-next.1 → 1.0.0-rc.3

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.
Files changed (170) hide show
  1. package/README.md +16 -8
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * PreToolUse guard (Bash): restrict read-only spokes to non-mutating shell
4
- * commands.
4
+ * commands. A "spoke" here is a subagent the hub dispatches a piece of work
5
+ * to (see CLAUDE.md's Agent Operating Model); a "read-only spoke" is one
6
+ * whose job is to inspect the repo -- review it, research it, run a
7
+ * diagnostic -- and never change it.
5
8
  *
6
9
  * Every reviewer/research spoke in `.claude/agents/*.md` declares itself
7
10
  * read-only in its system prompt and may hold the `Bash` tool for
@@ -18,8 +21,11 @@
18
21
  *
19
22
  * Scope: only tool calls made from inside one of those read-only subagents
20
23
  * are checked -- identified via the hook payload's `agent_type` field
21
- * (present when `PreToolUse` fires inside a subagent context; absent for
22
- * the hub's own Bash calls, which this hook does not restrict).
24
+ * (present when `PreToolUse` fires inside a subagent context, and also in a
25
+ * main session started with `claude --agent <name>`; absent for an ordinary
26
+ * hub's own Bash calls, which this hook does not restrict). Keeping the
27
+ * read-only restriction on a `--agent <read-only agent>` main session is
28
+ * fail-safe -- it only refuses more -- so no `agent_id` check is needed here.
23
29
  *
24
30
  * Design tradeoff, matching every sibling guard hook's fail-open
25
31
  * philosophy: this is a DENYLIST of known-mutating patterns, not a strict
@@ -29,6 +35,61 @@
29
35
  * merely defers to code review, which remains the authoritative backstop).
30
36
  * Extend MUTATING_PATTERNS as new gaps are found rather than flipping to an
31
37
  * allowlist.
38
+ *
39
+ * Known, accepted gaps in the prefix-verb/nested-shell handling (deliberate,
40
+ * per the tradeoff above -- not oversights): a prefix verb's OWN value-taking
41
+ * flag (`sudo -u root rm x`, `env -C /tmp rm x`, `xargs -n 1 rm`) resolves the
42
+ * flag's value as the verb, missing the real command. A nested shell's `-c`
43
+ * command string (standalone `-c` or a short-option cluster like `-lc`) is
44
+ * unwrapped and classified recursively wherever the verb resolves to a shell
45
+ * -- behind `sudo`/`env`/`xargs`/`command`, leading `VAR=value` assignments,
46
+ * or `npx`/`pnpm exec`/`pnpm dlx`/`npm exec` (whose own shell-mode flags,
47
+ * `-c`/`--call`/`--shell-mode`, are unwrapped the same way) -- but only for
48
+ * the shells `bash sh zsh dash ksh`; `eval`, `busybox sh` and a shell reached
49
+ * through an unrecognized wrapper (`nice`, `nohup`, `timeout`, `xargs -I{}`,
50
+ * ...) are not unwrapped unless listed -- `time` is the one such wrapper
51
+ * stripped, alongside the leading reserved words `if then else elif do while
52
+ * until ! {` and a glued `(`/`{`. Beyond that strip, shell grammar is not
53
+ * modelled: a `case ... esac` body (`pat) rm x ;;` resolves `pat)` as the
54
+ * verb), a function definition or call (`f() { rm x; }; f`) and an alias run
55
+ * whatever they wrap unseen. A wrapper's "tool" word that holds whitespace
56
+ * once unquoted (`npx "rm -rf src"`) is judged as a command string. Every word is unquoted
57
+ * the way the shell would before it is compared (`"rm"`, `r\m` and `git
58
+ * "commit"` match as `rm`/`git commit`); a verb or subcommand word, or a
59
+ * nested command string, that uses `$'...'` ANSI-C quoting or whose quote
60
+ * never closes blocks rather than being decoded. `find`'s `-exec` check only
61
+ * inspects the immediate next token, missing `-execdir`/`-ok` and a chained
62
+ * `-exec sh -c '...'`. Interpreters are not inspected either: a file write
63
+ * from inside `node -e`, `python -c`, `perl -e`, `ruby -e` or a script they
64
+ * run passes, since recognizing it would mean parsing another language.
65
+ * `git -c key=value`/`--config-env` is checked against GIT_EXEC_CONFIG_KEY, a
66
+ * DENYLIST of the known command-executing config keys (pagers, editors,
67
+ * aliases, credential helpers, textconv, ...) -- a command-running key git
68
+ * adds later, or one not on that list, passes; `include.path`/
69
+ * `includeIf.<cond>.path` and the `GIT_CONFIG*` file-swapping variables block,
70
+ * but a config FILE the user already controls (the repo's `.git/config`,
71
+ * `~/.gitconfig`) can still set an exec key and is never read. Leading `VAR=value`
72
+ * assignments ahead of `git` are likewise checked against GIT_EXEC_ENV, a
73
+ * DENYLIST of git's own command-executing environment variables; the
74
+ * equivalent variables of other tools (`PAGER`, `EDITOR`, `LESSOPEN`, ...)
75
+ * are not inspected at all, nor is a variable exported earlier in the session
76
+ * rather than on the command itself. Command substitution is only inspected
77
+ * for a redirect inside a double-quoted `"$(...)"`/backtick span; a bare
78
+ * `$(...)`/backtick substitution outside double quotes, `<(...)` process
79
+ * substitution and here-strings are not recursively classified, so
80
+ * `echo $(rm x)` passes. Chain operators (`&&`, `||`, `|`, `&`, `;`, newline)
81
+ * inside a quoted string do not split the command; a `bash -c '...'` payload
82
+ * therefore stays one segment and is re-split, with the same quote-aware
83
+ * rules, when it is classified recursively. The exec-config-key list stays a
84
+ * denylist even after its extension to drivers, filters and tool commands.
85
+ *
86
+ * `git branch` and `git tag` are the exceptions to the denylist design: their
87
+ * flag surfaces accept unique-prefix abbreviations (`--unset` for
88
+ * `--unset-upstream`), bundled/inline values (`-uorigin/main`) and
89
+ * value-taking flags whose separated value can look like a list flag (`git tag
90
+ * --format -l v2` creates `v2`), which no denylist of spellings can keep up
91
+ * with, so each is judged against an ALLOWLIST of its read-only flags
92
+ * (GIT_BRANCH_READ_FLAGS, GIT_TAG_READ_FLAGS) -- anything else blocks.
32
93
  */
33
94
  import process from "node:process";
34
95
  import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
@@ -44,13 +105,26 @@ async function readStdin() {
44
105
  return Buffer.concat(chunks).toString("utf8");
45
106
  }
46
107
 
47
- /** Extract the YAML frontmatter block's `name:` field, or `undefined`. */
108
+ /**
109
+ * Extract the YAML frontmatter block's `name:` field, or `undefined`. CRLF
110
+ * line endings are normalized first -- the frontmatter delimiter regex only
111
+ * matches a bare `\n`, so an untouched CRLF file would never match at all,
112
+ * silently producing zero read-only agents (which, per this hook's actual
113
+ * usage, means the guard exits 0 -- allow -- for every agent). A quoted
114
+ * value (`name: "some-agent"`) has its quotes stripped so it compares
115
+ * correctly against `WRITER_SPOKES` and the incoming `agent_type`.
116
+ */
48
117
  function frontmatterName(filePath) {
49
- const content = readFileSync(filePath, "utf8");
50
- const match = content.match(/^---\n([\s\S]*?)\n---/);
118
+ const content = readFileSync(filePath, "utf8")
119
+ .replace(/^\uFEFF/, "") // a leading BOM (a realistic artifact of a Windows editor)
120
+ .replace(/\r\n/g, "\n");
121
+ const match = content.match(/^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/);
51
122
  if (match === null) return undefined;
52
123
  const nameLine = match[1].split("\n").find((line) => /^name:\s*/.test(line));
53
- return nameLine?.replace(/^name:\s*/, "").trim();
124
+ return nameLine
125
+ ?.replace(/^name:\s*/, "")
126
+ .trim()
127
+ .replace(/^["']|["']$/g, "");
54
128
  }
55
129
 
56
130
  /**
@@ -72,14 +146,171 @@ export function readOnlyAgentNames(agentsDir) {
72
146
  }
73
147
 
74
148
  /**
75
- * Segment a shell command on `&&`/`||`/single `|`/`;`/newline chain operators.
76
- * A `|` immediately preceded by `>` is the clobber-redirect operator (`>|`),
77
- * not a pipe, so it must not split -- otherwise `echo x >| file` gets
78
- * chopped into "echo x >" and "file", hiding the redirect from the
79
- * write-detection regex in classifyBashCommand.
149
+ * The length of the chain operator (`&&`, `||`, `|`, `&`, `;`, newline)
150
+ * starting at `command[i]`, or 0 when there is none. A `|` immediately
151
+ * preceded by `>` is the clobber-redirect operator (`>|`), not a pipe --
152
+ * splitting it would chop `echo x >| file` into "echo x >" and "file", hiding
153
+ * the redirect. Likewise a lone `&` is the background operator (which runs
154
+ * the next command too) except where it belongs to a redirect: fd duplication
155
+ * (`2>&1`, `>&2`, `<&3`) or the `&>`/`&>>` both-streams redirect.
156
+ *
157
+ * @param {string} command
158
+ * @param {number} i
159
+ * @returns {number}
160
+ */
161
+ function chainOperatorLength(command, i) {
162
+ const c = command[i];
163
+ const next = command[i + 1];
164
+ if ((c === "&" && next === "&") || (c === "|" && next === "|")) return 2;
165
+ if (c === "|") return command[i - 1] === ">" ? 0 : 1;
166
+ if (c === "&") {
167
+ const prev = command[i - 1];
168
+ return prev === ">" || prev === "<" || next === ">" ? 0 : 1;
169
+ }
170
+ return c === ";" || c === "\n" ? 1 : 0;
171
+ }
172
+
173
+ /**
174
+ * Split `command` at every boundary `boundaryAt(command, i)` reports (the
175
+ * boundary's length, 0 for none) that does not sit inside a quoted string.
176
+ * Quotes pair the way a POSIX shell pairs them -- the same rules the redirect
177
+ * scan in classifyBashCommand uses: a backslash outside single quotes escapes
178
+ * the next character (so `\"` opens nothing and `"\""` stays open), a
179
+ * backslash inside single quotes is literal, and `$'...'` honors `\'`.
180
+ * Returns undefined when a quote never closes, so each caller can pick its own
181
+ * fail-closed fallback.
182
+ *
183
+ * @param {string} command
184
+ * @param {(command: string, i: number) => number} boundaryAt
185
+ * @returns {string[] | undefined}
186
+ */
187
+ function splitUnquoted(command, boundaryAt) {
188
+ /** @type {string[]} */
189
+ const parts = [];
190
+ let start = 0;
191
+ /** @type {"'" | '"' | "$'" | undefined} */
192
+ let quote;
193
+ for (let i = 0; i < command.length; i++) {
194
+ const c = command[i];
195
+ if (quote === "'") {
196
+ if (c === "'") quote = undefined;
197
+ } else if (c === "\\") {
198
+ i++; // escapes the next character (outside single quotes)
199
+ } else if (quote !== undefined) {
200
+ if (c === quote.at(-1)) quote = undefined;
201
+ } else if (c === "'" || c === '"') {
202
+ quote = c;
203
+ } else if (c === "$" && command[i + 1] === "'") {
204
+ quote = "$'";
205
+ i++;
206
+ } else {
207
+ const length = boundaryAt(command, i);
208
+ if (length > 0) {
209
+ parts.push(command.slice(start, i));
210
+ i += length - 1;
211
+ start = i + 1;
212
+ }
213
+ }
214
+ }
215
+ if (quote !== undefined) return undefined;
216
+ parts.push(command.slice(start));
217
+ return parts;
218
+ }
219
+
220
+ /**
221
+ * Segment a shell command on its `&&`/`||`/`|`/`&`/`;`/newline chain operators,
222
+ * ignoring any that sit inside a quoted string (`grep -E "a|b"`, `bash -c
223
+ * 'x; y'`) -- see splitUnquoted. An unbalanced quote falls back to splitting
224
+ * on every operator regardless of quoting (fail closed: an unterminated quote
225
+ * must not hide a real chain).
226
+ *
227
+ * @param {string} command
228
+ * @returns {string[]}
80
229
  */
81
230
  function segments(command) {
82
- return command.split(/&&|\|\||(?<!>)\||;|\n/).map((s) => s.trim());
231
+ const parts =
232
+ splitUnquoted(command, chainOperatorLength) ??
233
+ command.split(/&&|\|\||(?<!>)\||(?<![<>])&(?!>)|;|\n/);
234
+ return parts.map((s) => s.trim());
235
+ }
236
+
237
+ /**
238
+ * Split one segment into its shell words on unquoted whitespace, keeping each
239
+ * quoted span (quotes included) inside a single token, so `bash -c 'rm -rf
240
+ * src'`'s payload is one token with its original whitespace intact. An
241
+ * unbalanced quote falls back to a plain whitespace split.
242
+ *
243
+ * @param {string} segment
244
+ * @returns {string[]}
245
+ */
246
+ function words(segment) {
247
+ const parts =
248
+ splitUnquoted(segment, (s, i) => (/\s/.test(s[i]) ? 1 : 0)) ??
249
+ segment.split(/\s+/);
250
+ return parts.filter(Boolean);
251
+ }
252
+
253
+ /**
254
+ * The value a shell gives one word (see `words`), with its quoting removed:
255
+ * single quotes are literal, double quotes honor a backslash only before `$`,
256
+ * a backtick, `"`, `\` or a newline, and an unquoted backslash escapes the
257
+ * next character. Undefined when a quote never closes, or when the word uses
258
+ * `$'...'` ANSI-C quoting, whose escapes (`\n`, `\x3e`, ...) can produce chain
259
+ * operators and redirects this guard does not decode -- the caller fails
260
+ * closed on undefined.
261
+ *
262
+ * @param {string} word
263
+ * @returns {string | undefined}
264
+ */
265
+ function shellWordValue(word) {
266
+ if (word.includes("$'")) return undefined;
267
+ let out = "";
268
+ for (let i = 0; i < word.length; i++) {
269
+ const c = word[i];
270
+ if (c === "'") {
271
+ const end = word.indexOf("'", i + 1);
272
+ if (end === -1) return undefined;
273
+ out += word.slice(i + 1, end);
274
+ i = end;
275
+ } else if (c === '"') {
276
+ let j = i + 1;
277
+ for (; j < word.length && word[j] !== '"'; j++) {
278
+ if (word[j] === "\\" && j + 1 < word.length) {
279
+ if ('$`"\\\n'.includes(word[j + 1])) j++;
280
+ }
281
+ out += word[j];
282
+ }
283
+ if (j >= word.length) return undefined;
284
+ i = j;
285
+ } else if (c === "\\") {
286
+ i++;
287
+ out += word[i] ?? "";
288
+ } else {
289
+ out += c;
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /**
296
+ * One shell word: `raw` is its source text (quotes included), `value` what the
297
+ * shell makes of it (shellWordValue) -- undefined when it cannot be read.
298
+ * Every verb/subcommand/flag comparison uses `value` (or `raw` when there is
299
+ * none), so `"rm" x`, `r\m x` and `git "commit"` match exactly as bash runs
300
+ * them; `raw` is kept for the things quoting itself decides: whether a word is
301
+ * a `VAR=value` assignment, and a nested `-c` payload, which is re-parsed.
302
+ *
303
+ * @typedef {{ raw: string, value: string | undefined }} Word
304
+ */
305
+
306
+ /** @param {string} raw @returns {Word} */
307
+ function toWord(raw) {
308
+ return { raw, value: shellWordValue(raw) };
309
+ }
310
+
311
+ /** The text a word is matched by: its unquoted value, else its raw text. */
312
+ function wordText(word) {
313
+ return word.value ?? word.raw;
83
314
  }
84
315
 
85
316
  /** Strip a leading path (e.g. `/usr/bin/rm` -> `rm`) for verb comparison. */
@@ -88,13 +319,170 @@ function baseName(token) {
88
319
  return parts[parts.length - 1];
89
320
  }
90
321
 
322
+ // A "prefix verb" runs some OTHER command as its argument rather than
323
+ // mutating anything itself -- without unwrapping it, the real command
324
+ // hidden behind `sudo rm -rf x`, `env FOO=bar rm x`, or `xargs rm` is never
325
+ // inspected at all, and passes as if it were the harmless prefix alone.
326
+ const PREFIX_VERBS = new Set(["sudo", "env", "xargs", "command"]);
327
+
328
+ // A leading `VAR=value` environment assignment; group 1 is the name.
329
+ const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/;
330
+
331
+ /**
332
+ * The index just past the `VAR=value` assignment starting at `tokens[i]`. The
333
+ * command is split on whitespace, so a quoted value containing a space
334
+ * (`FOO='!touch pwn1'`) spans several tokens -- consume up to the one closing
335
+ * the quote, or the rest of the command when it never closes.
336
+ *
337
+ * @param {string[]} tokens
338
+ * @param {number} i
339
+ * @returns {number}
340
+ */
341
+ function assignmentEnd(tokens, i) {
342
+ const value = tokens[i].slice(tokens[i].indexOf("=") + 1);
343
+ const quote = value[0];
344
+ if (quote !== "'" && quote !== '"') return i + 1;
345
+ if (value.length > 1 && value.endsWith(quote)) return i + 1;
346
+ let j = i + 1;
347
+ while (j < tokens.length && !tokens[j].endsWith(quote)) j++;
348
+ return Math.min(j + 1, tokens.length);
349
+ }
350
+
351
+ // Shell reserved words (and `time`, a keyword in bash/zsh) that can sit in
352
+ // front of the command a segment actually runs -- `do rm x`, `then rm x`,
353
+ // `! rm x`, `{ rm x`, `time rm x`. `segments` already splits on `;`/newline,
354
+ // so each of these is the first word of the segment holding its command.
355
+ const LEADING_RESERVED_WORDS = new Set([
356
+ "if",
357
+ "then",
358
+ "else",
359
+ "elif",
360
+ "do",
361
+ "while",
362
+ "until",
363
+ "!",
364
+ "{",
365
+ "time",
366
+ ]);
367
+
368
+ // Reserved words opening a segment that names no command at all: the `for
369
+ // NAME in WORDS` / `select NAME in WORDS` / `case WORD in` header. The body
370
+ // that follows (`do ...`, `pattern) ...`) is its own segment.
371
+ const HEADER_RESERVED_WORDS = new Set(["for", "select", "case"]);
372
+
373
+ /**
374
+ * Drops a chain of leading shell reserved words/grouping tokens
375
+ * (LEADING_RESERVED_WORDS, a `(`/`{` glued to the first word, `time`'s own
376
+ * flags), `VAR=value` assignments and prefix verbs (with their own
377
+ * flags/assignments) so the REAL command is what `parseTokens` resolves the
378
+ * verb/subcommand from. A `for`/`select`/`case` header runs nothing and
379
+ * resolves to no words. The names of every stripped assignment are returned
380
+ * too, since an environment variable can itself make the real command run
381
+ * something (`GIT_PAGER=sh git log`).
382
+ *
383
+ * An assignment is recognized from the RAW word, as the shell does (a quoted
384
+ * `"FOO=1"` is a command name, not an assignment); reserved words, prefix
385
+ * verbs and their flags are matched by their unquoted text.
386
+ *
387
+ * @param {Word[]} input
388
+ * @returns {{ words: Word[], assignments: string[] }}
389
+ */
390
+ function stripPrefixVerbs(input) {
391
+ /** @type {string[]} */
392
+ const assignments = [];
393
+ const words = [...input];
394
+ const raw = (k) => words[k].raw;
395
+ const text = (k) => wordText(words[k]);
396
+ let i = 0;
397
+ const skipAssignments = (allowFlags) => {
398
+ while (i < words.length) {
399
+ const name = ASSIGNMENT.exec(raw(i))?.[1];
400
+ if (name !== undefined) {
401
+ assignments.push(name);
402
+ i = assignmentEnd(
403
+ words.map((w) => w.raw),
404
+ i,
405
+ );
406
+ } else if (allowFlags && text(i).startsWith("-")) {
407
+ i++;
408
+ } else {
409
+ return;
410
+ }
411
+ }
412
+ };
413
+ const skipReserved = () => {
414
+ while (i < words.length) {
415
+ const glued = /^[({]+/.exec(raw(i))?.[0];
416
+ if (glued !== undefined) {
417
+ const rest = raw(i).slice(glued.length);
418
+ if (rest === "") i++;
419
+ else words[i] = toWord(rest);
420
+ } else if (LEADING_RESERVED_WORDS.has(text(i))) {
421
+ const isTime = text(i) === "time";
422
+ i++;
423
+ if (isTime) while (i < words.length && text(i).startsWith("-")) i++;
424
+ } else {
425
+ return;
426
+ }
427
+ }
428
+ };
429
+ for (;;) {
430
+ const start = i;
431
+ skipReserved();
432
+ if (i < words.length && HEADER_RESERVED_WORDS.has(text(i))) {
433
+ return { words: [], assignments };
434
+ }
435
+ skipAssignments(false);
436
+ if (i < words.length && PREFIX_VERBS.has(baseName(text(i)))) {
437
+ // `command -v`/`-V` looks a name up (prints its path/description) and
438
+ // does not run it -- unlike every other use of `command` (and unlike
439
+ // `sudo`/`env`/`xargs`), the following token is never executed, so it
440
+ // must not be peeled off as the "real" verb.
441
+ const next = i + 1 < words.length ? text(i + 1) : undefined;
442
+ if (baseName(text(i)) === "command" && (next === "-v" || next === "-V")) {
443
+ break;
444
+ }
445
+ i++;
446
+ skipAssignments(true);
447
+ }
448
+ if (i === start) break;
449
+ }
450
+ return { words: words.slice(i), assignments };
451
+ }
452
+
453
+ /**
454
+ * Drop a subshell's closing `)` glued to a segment's last word (`(git
455
+ * commit)` -> `git commit`), so it cannot defeat a verb/subcommand match. Only
456
+ * surplus `)` are dropped -- a balanced `$(date)` is left intact -- and a word
457
+ * that is nothing but `)` disappears.
458
+ *
459
+ * @param {Word[]} list
460
+ * @returns {Word[]}
461
+ */
462
+ function stripClosingParens(list) {
463
+ const last = list.at(-1);
464
+ if (last === undefined) return list;
465
+ let end = last.raw;
466
+ const opens = (end.match(/\(/g) ?? []).length;
467
+ while (
468
+ end.endsWith(")") &&
469
+ !end.endsWith("\\)") &&
470
+ (end.match(/\)/g) ?? []).length > opens
471
+ ) {
472
+ end = end.slice(0, -1);
473
+ }
474
+ if (end === last.raw) return list;
475
+ const head = list.slice(0, -1);
476
+ return end === "" ? head : [...head, toWord(end)];
477
+ }
478
+
91
479
  // Global/wrapper flags -- per verb -- that consume the FOLLOWING token as
92
480
  // their value, so the walk to find the subcommand must skip both. Without
93
481
  // this, `git -C /tmp commit` or `pnpm --dir ./foo add lodash` resolve `sub`
94
482
  // to the flag's *value* ("/tmp", "./foo") instead of the real subcommand,
95
483
  // defeating MUTATING_SUBCOMMANDS entirely (a `--flag=value` inline form
96
484
  // needs no entry here -- the value stays on the same token, which
97
- // parseSegment already skips as "starts with -").
485
+ // parseTokens already skips as "starts with -").
98
486
  const FLAGS_WITH_VALUE = {
99
487
  git: new Set([
100
488
  "-c",
@@ -103,43 +491,77 @@ const FLAGS_WITH_VALUE = {
103
491
  "--work-tree",
104
492
  "--namespace",
105
493
  "--exec-path",
494
+ "--config-env",
495
+ "--attr-source",
496
+ "--super-prefix",
497
+ "--list-cmds",
106
498
  ]),
107
- pnpm: new Set(["-C", "--dir", "--filter", "--filter-prod"]),
499
+ pnpm: new Set(["-C", "--dir", "-F", "--filter", "--filter-prod"]),
108
500
  npm: new Set(["-C", "--prefix"]),
501
+ // `npx -p pkg tool` / `pnpm dlx --package pkg tool`: the package to fetch,
502
+ // not the tool that runs.
503
+ npx: new Set(["-p", "--package"]),
109
504
  };
110
505
 
111
506
  /**
112
- * The command verb and (for multi-word CLIs) subcommand of one shell
113
- * segment, skipping leading `VAR=value` environment assignments and any
114
- * global flags (per FLAGS_WITH_VALUE) that appear before the subcommand.
507
+ * Index of the first positional (non-flag) token at or after `start`,
508
+ * skipping each flag in `valueFlags` together with its value token; -1 when
509
+ * there is none.
115
510
  *
116
- * @param {string} segment
117
- * @returns {{ verb: string, sub: string | undefined, tokens: string[] }}
511
+ * @param {string[]} tokens
512
+ * @param {number} start
513
+ * @param {Set<string>} valueFlags
514
+ * @returns {number}
118
515
  */
119
- function parseSegment(segment) {
120
- const tokens = segment.split(/\s+/).filter(Boolean);
121
- let i = 0;
122
- while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
123
- if (tokens[i] === undefined) return { verb: "", sub: undefined, tokens: [] };
124
-
125
- const verb = baseName(tokens[i]);
126
- const valueFlags = FLAGS_WITH_VALUE[verb] ?? new Set();
127
- let sub;
128
- let j = i + 1;
516
+ function firstPositionalIndex(tokens, start, valueFlags) {
517
+ let j = start;
129
518
  while (j < tokens.length) {
130
519
  const t = tokens[j];
131
520
  if (valueFlags.has(t)) {
132
521
  j += 2; // skip the flag AND its value token
133
- continue;
134
- }
135
- if (t.startsWith("-")) {
522
+ } else if (t.startsWith("-")) {
136
523
  j += 1; // a flag that doesn't consume a following value
137
- continue;
524
+ } else {
525
+ return j;
138
526
  }
139
- sub = t;
140
- break;
141
527
  }
142
- return { verb, sub, tokens: tokens.slice(i) };
528
+ return -1;
529
+ }
530
+
531
+ /**
532
+ * The command verb and (for multi-word CLIs) subcommand of an already
533
+ * tokenized command, skipping leading `VAR=value` environment assignments
534
+ * and any global flags (per FLAGS_WITH_VALUE) that appear before the
535
+ * subcommand. `subIndex` is the subcommand's index in `tokens` (-1 when there
536
+ * is none), so a caller can read its arguments as `tokens.slice(subIndex + 1)`.
537
+ * `assignments` names every stripped environment assignment. `tokens` holds
538
+ * each remaining word's matching text (wordText), index-aligned with `words`.
539
+ *
540
+ * @param {Word[]} rawWords
541
+ * @returns {{ verb: string, sub: string | undefined, subIndex: number, words: Word[], tokens: string[], assignments: string[] }}
542
+ */
543
+ function parseTokens(rawWords) {
544
+ const { words, assignments } = stripPrefixVerbs(rawWords);
545
+ const tokens = words.map(wordText);
546
+ if (tokens.length === 0) {
547
+ return {
548
+ verb: "",
549
+ sub: undefined,
550
+ subIndex: -1,
551
+ words,
552
+ tokens,
553
+ assignments,
554
+ };
555
+ }
556
+
557
+ const verb = baseName(tokens[0]);
558
+ const subIndex = firstPositionalIndex(
559
+ tokens,
560
+ 1,
561
+ FLAGS_WITH_VALUE[verb] ?? new Set(),
562
+ );
563
+ const sub = subIndex === -1 ? undefined : tokens[subIndex];
564
+ return { verb, sub, subIndex, words, tokens, assignments };
143
565
  }
144
566
 
145
567
  // Mutating subcommands per top-level verb (e.g. "git" -> "commit"). A verb
@@ -150,6 +572,7 @@ const MUTATING_SUBCOMMANDS = {
150
572
  "add",
151
573
  "commit",
152
574
  "push",
575
+ "pull", // fetch + merge/rebase into the working tree
153
576
  "merge",
154
577
  "rebase",
155
578
  "cherry-pick",
@@ -163,21 +586,28 @@ const MUTATING_SUBCOMMANDS = {
163
586
  "am",
164
587
  "revert",
165
588
  "restore",
589
+ "rm",
590
+ "mv",
166
591
  "gc",
167
592
  "worktree", // add/remove mutate the tree layout
168
593
  "config",
169
- "stash", // push/pop/drop/apply mutate the working tree; `stash list` is
170
- // read-only but the false positive here is cheap -- use `git stash
171
- // list` sparingly from a read-only spoke, or defer to the hub.
594
+ "stash", // push/pop/drop/apply mutate the working tree; the read-only
595
+ // forms (`stash list`/`stash show`) are exempted by
596
+ // READ_ONLY_GIT_FORMS below.
172
597
  ]),
173
598
  pnpm: new Set([
599
+ "install",
600
+ "i",
174
601
  "add",
175
602
  "remove",
176
603
  "rm",
604
+ "update",
177
605
  "publish",
178
606
  "version",
179
607
  "link",
180
608
  "unlink",
609
+ "format", // the conventional formatter-write script
610
+ "lint:fix", // the conventional lint-autofix script
181
611
  ]),
182
612
  npm: new Set([
183
613
  "install",
@@ -186,13 +616,274 @@ const MUTATING_SUBCOMMANDS = {
186
616
  "remove",
187
617
  "rm",
188
618
  "uninstall",
619
+ "update",
189
620
  "publish",
190
621
  "version",
191
622
  "link",
192
623
  "unlink",
624
+ "format",
625
+ "lint:fix",
193
626
  ]),
194
627
  };
195
628
 
629
+ /** @typedef {{ value: "none" | "optional" | "required", pattern?: true }} ReadFlagSpec */
630
+
631
+ // The ALLOWLISTS of `git branch`'s and `git tag`'s read-only flags (see the
632
+ // module header for why these two are allowlisted), keyed by exact flag name.
633
+ // `value` says how the flag takes an argument: "none" (no `=value` form
634
+ // accepted), "optional" (inline `--flag=value` only), or "required" (inline,
635
+ // or else the following token is consumed as its value). `pattern` marks the
636
+ // flags that put the command in list mode, where a positional is a pattern to
637
+ // match rather than a name to create -- `-v`/`-a`/`-r` do NOT (`git branch -v
638
+ // newb` creates `newb`).
639
+ const GIT_BRANCH_READ_FLAGS = new Map(
640
+ /** @type {[string, ReadFlagSpec][]} */ ([
641
+ ["--show-current", { value: "none" }],
642
+ ["-a", { value: "none" }],
643
+ ["--all", { value: "none" }],
644
+ ["-r", { value: "none" }],
645
+ ["--remotes", { value: "none" }],
646
+ ["-v", { value: "none" }],
647
+ ["-vv", { value: "none" }],
648
+ ["--verbose", { value: "none" }],
649
+ ["-i", { value: "none" }],
650
+ ["--ignore-case", { value: "none" }],
651
+ ["--omit-empty", { value: "none" }],
652
+ ["--no-color", { value: "none" }],
653
+ ["--no-column", { value: "none" }],
654
+ ["--no-abbrev", { value: "none" }],
655
+ ["--color", { value: "optional" }],
656
+ ["--column", { value: "optional" }],
657
+ ["--abbrev", { value: "optional" }],
658
+ ["--sort", { value: "required" }],
659
+ ["--format", { value: "required" }],
660
+ ["--list", { value: "none", pattern: true }],
661
+ ["-l", { value: "none", pattern: true }],
662
+ ["--merged", { value: "optional", pattern: true }],
663
+ ["--no-merged", { value: "optional", pattern: true }],
664
+ ["--contains", { value: "optional", pattern: true }],
665
+ ["--no-contains", { value: "optional", pattern: true }],
666
+ ["--points-at", { value: "optional", pattern: true }],
667
+ ]),
668
+ );
669
+
670
+ // `git tag`'s counterpart of GIT_BRANCH_READ_FLAGS. `-n` also accepts attached
671
+ // digits (`-n3`), normalized to `-n` before lookup by isReadOnlyGitTag; it
672
+ // implies list mode, so a following positional is a pattern.
673
+ const GIT_TAG_READ_FLAGS = new Map(
674
+ /** @type {[string, ReadFlagSpec][]} */ ([
675
+ ["-n", { value: "none", pattern: true }],
676
+ ["-i", { value: "none" }],
677
+ ["--ignore-case", { value: "none" }],
678
+ ["--omit-empty", { value: "none" }],
679
+ ["--no-color", { value: "none" }],
680
+ ["--no-column", { value: "none" }],
681
+ ["--color", { value: "optional" }],
682
+ ["--column", { value: "optional" }],
683
+ ["--sort", { value: "required" }],
684
+ ["--format", { value: "required" }],
685
+ ["-l", { value: "none", pattern: true }],
686
+ ["--list", { value: "none", pattern: true }],
687
+ ["--contains", { value: "optional", pattern: true }],
688
+ ["--no-contains", { value: "optional", pattern: true }],
689
+ ["--merged", { value: "optional", pattern: true }],
690
+ ["--no-merged", { value: "optional", pattern: true }],
691
+ ["--points-at", { value: "required", pattern: true }],
692
+ ]),
693
+ );
694
+
695
+ /**
696
+ * Whether `<args>` is a read-only invocation per the flag allowlist `table`:
697
+ * every flag is on it (in a form it accepts), and there is either no
698
+ * positional or a pattern-taking flag that makes positionals patterns. A
699
+ * "required"-value flag given without `=` consumes the next token, so that
700
+ * value can never be mistaken for a flag (`--format -l v2` is create mode).
701
+ *
702
+ * @param {Map<string, ReadFlagSpec>} table
703
+ * @param {string[]} args
704
+ * @returns {boolean}
705
+ */
706
+ function isReadOnlyByFlagTable(table, args) {
707
+ let listMode = false;
708
+ let positional = false;
709
+ for (let k = 0; k < args.length; k++) {
710
+ const arg = args[k];
711
+ if (!arg.startsWith("-")) {
712
+ positional = true;
713
+ continue;
714
+ }
715
+ const eq = arg.indexOf("=");
716
+ const name = eq === -1 ? arg : arg.slice(0, eq);
717
+ const spec = table.get(name);
718
+ if (spec === undefined) return false; // unknown, abbreviated or bundled
719
+ if (eq !== -1 && spec.value === "none") return false;
720
+ if (spec.pattern === true) listMode = true;
721
+ if (eq === -1 && spec.value === "required") k++; // consume its value
722
+ }
723
+ return !positional || listMode;
724
+ }
725
+
726
+ /** `git branch <args>` is read-only, per GIT_BRANCH_READ_FLAGS. */
727
+ function isReadOnlyGitBranch(args) {
728
+ return isReadOnlyByFlagTable(GIT_BRANCH_READ_FLAGS, args);
729
+ }
730
+
731
+ /** `git tag <args>` is read-only, per GIT_TAG_READ_FLAGS (`-n3` -> `-n`). */
732
+ function isReadOnlyGitTag(args) {
733
+ return isReadOnlyByFlagTable(
734
+ GIT_TAG_READ_FLAGS,
735
+ args.map((a) => (/^-n\d+$/.test(a) ? "-n" : a)),
736
+ );
737
+ }
738
+
739
+ // `git -c <key>=<value>` / `--config-env=<key>=<env>` keys that make git run
740
+ // an arbitrary command (a pager, editor, alias with `!`, credential helper,
741
+ // textconv filter, diff/merge driver, clean/smudge filter, upload-pack
742
+ // override, difftool/mergetool/browser/man viewer command, ...). A DENYLIST
743
+ // of the known ones -- see the module header's accepted gaps. Matched
744
+ // case-insensitively, as git's keys are.
745
+ const GIT_EXEC_CONFIG_KEY = new RegExp(
746
+ `^(?:${[
747
+ String.raw`.*\.pager`,
748
+ String.raw`pager\..+`,
749
+ String.raw`core\.(?:editor|fsmonitor|sshcommand|askpass|hookspath|gitproxy)`,
750
+ String.raw`alias\..+`,
751
+ String.raw`credential\.(?:.+\.)?helper`,
752
+ String.raw`diff\.external`,
753
+ String.raw`diff\..+\.command`,
754
+ String.raw`.*\.textconv`,
755
+ String.raw`filter\..+\.(?:clean|smudge|process)`,
756
+ String.raw`merge\..+\.driver`,
757
+ String.raw`remote\..+\.(?:uploadpack|receivepack|proxy|vcs)`,
758
+ String.raw`(?:diff|merge)tool\..+\.cmd`,
759
+ String.raw`web\.browser`,
760
+ String.raw`browser\..+\.cmd`,
761
+ String.raw`man\..+\.cmd`,
762
+ String.raw`sequence\.editor`,
763
+ String.raw`gpg\.(?:.+\.)?program`,
764
+ // Not an exec key itself, but pulls in an arbitrary config file that can
765
+ // set any of the keys above.
766
+ String.raw`include(?:if\..+)?\.path`,
767
+ ].join("|")})$`,
768
+ "i",
769
+ );
770
+
771
+ /**
772
+ * The first `-c`/`--config-env` override among git's global flags (the
773
+ * tokens before the subcommand) whose key executes a command, or undefined.
774
+ *
775
+ * @param {string[]} globalTokens
776
+ * @returns {string | undefined} the offending key
777
+ */
778
+ function gitExecConfigKey(globalTokens) {
779
+ for (let k = 0; k < globalTokens.length; k++) {
780
+ const t = globalTokens[k];
781
+ let assignment;
782
+ if (t === "-c" || t === "--config-env") assignment = globalTokens[k + 1];
783
+ else if (t.startsWith("--config-env=")) {
784
+ assignment = t.slice("--config-env=".length);
785
+ }
786
+ if (assignment === undefined) continue;
787
+ const key = assignment.replace(/^["']/, "").split("=")[0];
788
+ if (GIT_EXEC_CONFIG_KEY.test(key)) return key;
789
+ }
790
+ return undefined;
791
+ }
792
+
793
+ // Environment variables that make git run an arbitrary command (a diff
794
+ // driver, pager, editor, ssh transport, askpass/proxy helper) or inject a
795
+ // config override the way `-c` does (GIT_CONFIG_PARAMETERS, the
796
+ // GIT_CONFIG_COUNT/KEY_n/VALUE_n triple), or point git at another program
797
+ // directory or hook template, or swap in another config file wholesale
798
+ // (GIT_CONFIG, GIT_CONFIG_GLOBAL/SYSTEM, GIT_CONFIG_NOSYSTEM), which can set
799
+ // any exec key. Any value blocks -- the variable itself is the exec surface.
800
+ const GIT_EXEC_ENV =
801
+ /^GIT_(?:EXTERNAL_DIFF|PAGER|EDITOR|SEQUENCE_EDITOR|SSH|SSH_COMMAND|ASKPASS|PROXY_COMMAND|CONFIG|CONFIG_GLOBAL|CONFIG_SYSTEM|CONFIG_NOSYSTEM|CONFIG_PARAMETERS|CONFIG_COUNT|CONFIG_KEY_\w+|CONFIG_VALUE_\w+|EXEC_PATH|TEMPLATE_DIR)$/;
802
+
803
+ const GIT_CONFIG_READ = new Set([
804
+ "--get",
805
+ "--get-all",
806
+ "--get-regexp",
807
+ "--get-urlmatch",
808
+ "--list",
809
+ "-l",
810
+ ]);
811
+ const GIT_CONFIG_WRITE = new Set([
812
+ "--add",
813
+ "--unset",
814
+ "--unset-all",
815
+ "--replace-all",
816
+ "--edit",
817
+ "-e",
818
+ "--rename-section",
819
+ "--remove-section",
820
+ ]);
821
+ // Git subcommands in MUTATING_SUBCOMMANDS that also have a purely read-only
822
+ // form, keyed by subcommand: each predicate takes the subcommand's arguments
823
+ // and returns true only for that read-only form. Anything else falls through
824
+ // to the MUTATING_SUBCOMMANDS block, so an unrecognized flag still blocks.
825
+ const READ_ONLY_GIT_FORMS = {
826
+ branch: isReadOnlyGitBranch,
827
+ worktree: (args) => args[0] === "list",
828
+ stash: (args) => args[0] === "list" || args[0] === "show",
829
+ // The legacy read flags (`--get`, `--list`, ...) or git >= 2.46's `get`/
830
+ // `list` subcommands; its `set`/`unset`/`edit`/... subcommands and the
831
+ // legacy `git config <name> <value>` write form still block.
832
+ config: (args) =>
833
+ (args[0] === "get" ||
834
+ args[0] === "list" ||
835
+ args.some((a) => GIT_CONFIG_READ.has(a))) &&
836
+ !args.some((a) => GIT_CONFIG_WRITE.has(a)),
837
+ tag: isReadOnlyGitTag,
838
+ };
839
+
840
+ // ESLint's one writing flag: `--fix` (or `--fix=<bool>`). `--fix-dry-run`
841
+ // only reports, and `--fix-type` merely narrows what a `--fix` would touch.
842
+ const ESLINT_FIX = /^--fix(?:=.*)?$/;
843
+
844
+ /**
845
+ * Whether a formatter/linter invocation writes files: `prettier --write`/`-w`
846
+ * or `eslint --fix`. Any other tool returns undefined.
847
+ *
848
+ * @param {string} verb
849
+ * @param {string[]} tokens
850
+ * @returns {string | undefined} the offending flag, or undefined
851
+ */
852
+ function fixerWriteFlag(verb, tokens) {
853
+ if (verb === "prettier") {
854
+ return tokens.find((t) => t === "--write" || t === "-w");
855
+ }
856
+ if (verb === "eslint") return tokens.find((t) => ESLINT_FIX.test(t));
857
+ return undefined;
858
+ }
859
+
860
+ // A package spec pinned to a version or dist-tag (`prettier@3`,
861
+ // `@scope/tool@latest`); group 1 is the bare package name, scope kept.
862
+ const PINNED_PACKAGE = /^(@[^/@]+\/[^@]+|[^@]+)@[^/]+$/;
863
+
864
+ /**
865
+ * The tool a wrapper runs, with any `@<version>` pin stripped, so a pinned
866
+ * `prettier@3` is judged as `prettier`. A scoped name keeps its scope.
867
+ *
868
+ * @param {string} spec
869
+ * @returns {string}
870
+ */
871
+ function unpinnedToolName(spec) {
872
+ return PINNED_PACKAGE.exec(spec)?.[1] ?? spec;
873
+ }
874
+
875
+ // Conventional lint/format script names (run via `pnpm <script>`/`pnpm run
876
+ // <script>`/`npm run <script>`) that forward trailing args to a fixer-capable
877
+ // tool, and the fixer flags that make one of them rewrite files.
878
+ const FIXER_SCRIPTS = new Set([
879
+ "lint",
880
+ "eslint",
881
+ "prettier",
882
+ "format",
883
+ "lint:fix",
884
+ ]);
885
+ const FIXER_FLAG = /^(?:--fix(?:=.*)?|--write|-w)$/;
886
+
196
887
  // Verbs that mutate the filesystem regardless of subcommand.
197
888
  const MUTATING_VERBS = new Set([
198
889
  "rm",
@@ -208,6 +899,87 @@ const MUTATING_VERBS = new Set([
208
899
  "tee",
209
900
  ]);
210
901
 
902
+ // `bash -c '...'`/`sh -c "..."` (optionally through a path like
903
+ // `/bin/bash`), capturing the quoted argument's inner content.
904
+ const SHELL_DASH_C =
905
+ /^\s*(?:\S*\/)?(?:bash|sh)\s+(?:-\S+\s+)*-c\s+(['"])([\s\S]*)\1\s*$/;
906
+
907
+ // Shells whose `-c <command>` is unwrapped wherever a verb resolves to one --
908
+ // behind a prefix verb, an env assignment or a tool wrapper -- and the shell
909
+ // options that consume the following token as their value.
910
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
911
+ const SHELL_VALUE_OPTIONS = new Set([
912
+ "-o",
913
+ "+o",
914
+ "-O",
915
+ "+O",
916
+ "--rcfile",
917
+ "--init-file",
918
+ ]);
919
+
920
+ // A tool wrapper's own flags that run their value(s) as a shell command
921
+ // (`npx -c '<cmd>'`, `npm exec --call '<cmd>'`, `pnpm exec --shell-mode
922
+ // <cmd>`), keyed by wrapper verb.
923
+ const NPX_SHELL_FLAGS = new Set(["-c", "--call"]);
924
+ const WRAPPER_SHELL_FLAGS = {
925
+ npx: NPX_SHELL_FLAGS,
926
+ pnpx: NPX_SHELL_FLAGS,
927
+ npm: NPX_SHELL_FLAGS,
928
+ pnpm: new Set(["-c", "--shell-mode"]),
929
+ };
930
+
931
+ /**
932
+ * For a shell invocation (`tokens[0]` is the shell), the index of the command
933
+ * string its `-c` option runs -- `-c` alone or inside a short-option cluster
934
+ * (`-lc`, `-ec`) -- which is the first operand after the options. -1 when no
935
+ * `-c` is given (a script or an interactive shell); `tokens.length` when `-c`
936
+ * is given but no operand follows.
937
+ *
938
+ * @param {string[]} tokens
939
+ * @returns {number}
940
+ */
941
+ function shellCommandIndex(tokens) {
942
+ let dashC = false;
943
+ for (let j = 1; j < tokens.length; j++) {
944
+ const t = tokens[j];
945
+ if (t === "--" || t === "-") return dashC ? j + 1 : -1;
946
+ if (SHELL_VALUE_OPTIONS.has(t)) {
947
+ j++; // skip the option's value
948
+ } else if (/^[-+]/.test(t)) {
949
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(t)) dashC = true;
950
+ } else {
951
+ return dashC ? j : -1;
952
+ }
953
+ }
954
+ return dashC ? tokens.length : -1;
955
+ }
956
+
957
+ /**
958
+ * Classify a nested shell command given as shell words (see `words`), which a
959
+ * shell or wrapper `via` runs: each word's unquoted value is joined and the
960
+ * result classified recursively with classifyBashCommand. Fails closed --
961
+ * blocks -- when there is no word or one cannot be read.
962
+ *
963
+ * @param {string} via e.g. `bash -c`, `npx -c`
964
+ * @param {Word[]} payloadWords
965
+ * @returns {{ blocked: true, reason: string } | undefined}
966
+ */
967
+ function classifyNestedCommand(via, payloadWords) {
968
+ const values = payloadWords.map((w) => w.value);
969
+ if (values.length === 0 || values.includes(undefined)) {
970
+ return {
971
+ blocked: true,
972
+ reason: `runs a nested shell command via ${via} whose command string could not be read`,
973
+ };
974
+ }
975
+ const nested = classifyBashCommand(values.join(" "));
976
+ if (!nested.blocked) return undefined;
977
+ return {
978
+ blocked: true,
979
+ reason: `runs a nested shell command via ${via} that ${nested.reason}`,
980
+ };
981
+ }
982
+
211
983
  /**
212
984
  * Classify a shell command as blocked (mutating) or allowed for a read-only
213
985
  * spoke. Denylist-based -- see the module header for the design tradeoff.
@@ -223,17 +995,64 @@ export function classifyBashCommand(command) {
223
995
  for (const segment of segments(command)) {
224
996
  if (segment.length === 0) continue;
225
997
 
998
+ // `bash -c '<command>'`/`sh -c '<command>'` runs the quoted argument as
999
+ // its own shell command -- unwrap and recurse rather than reading only
1000
+ // the outer `bash -c` invocation, which is never itself mutating.
1001
+ const nestedShell = SHELL_DASH_C.exec(segment);
1002
+ if (nestedShell !== null) {
1003
+ const nested = classifyBashCommand(nestedShell[2]);
1004
+ if (nested.blocked) {
1005
+ return {
1006
+ blocked: true,
1007
+ reason: `runs a nested shell command via -c that ${nested.reason}`,
1008
+ };
1009
+ }
1010
+ continue;
1011
+ }
1012
+
226
1013
  // Write-redirection to a real file (not a discard target). No digit
227
1014
  // lookbehind: `1>file`/`2>file` are ordinary fd-prefixed writes, not fd
228
- // duplication -- `2>&1` is excluded below because its target starts
229
- // with `&`, which the target class already rejects. Global flag: a
1015
+ // duplication -- `2>&1` is excluded below as a `>&` operator whose target
1016
+ // is a digit string (see the `>&word` note). Global flag: a
230
1017
  // segment can carry more than one redirect (`cmd > /dev/null > real`),
231
1018
  // and a decoy discard target must not short-circuit the scan past a
232
1019
  // real one that follows it.
233
- for (const redirect of segment.matchAll(/(>{1,2}\|?)\s*([^\s&|;]+)/g)) {
234
- if (
235
- !/^(\/dev\/null|nul)$/i.test(redirect[2].replace(/^["']|["']$/g, ""))
236
- ) {
1020
+ //
1021
+ // Quoted spans are blanked first unless they ARE a redirect's target, so
1022
+ // a `>` inside a quoted argument (`grep "=> {" src`, `--format="%h > %s"`)
1023
+ // is not mistaken for a redirect, while `echo x > "out.txt"` keeps its
1024
+ // quoted target and is classified exactly as an unquoted one. Quotes are
1025
+ // paired the way a POSIX shell pairs them: a backslash-escaped quote
1026
+ // outside quotes (`\"`) is a literal and opens nothing, `\"` inside double
1027
+ // quotes does not close them, and a backslash inside single quotes is
1028
+ // literal. An unbalanced quote is left un-blanked (fail closed).
1029
+ //
1030
+ // Two cases skip blanking, also failing closed. A double-quoted span
1031
+ // holding a `$(...)`/backtick command substitution is kept, since the
1032
+ // shell still runs the substitution -- a `>` inside it is a real redirect
1033
+ // (`echo "$(date > out)"`). And a segment using `$'...'` ANSI-C quoting is
1034
+ // not blanked at all: its `\'` escape pairs differently from a plain
1035
+ // single quote, so the pairing above would desync and swallow a real
1036
+ // redirect that follows (`echo $'\'' > out #'`).
1037
+ const scan = segment.includes("$'")
1038
+ ? segment
1039
+ : segment.replace(
1040
+ /(>{1,2}[|&]?\s*)?(?<!\\)(?:'[^']*'|"(?:\\[\s\S]|[^\\"])*")/g,
1041
+ (match, redirectOp) =>
1042
+ redirectOp !== undefined ||
1043
+ (match[0] === '"' && /\$\(|`/.test(match))
1044
+ ? match
1045
+ : `${match[0]}${match[0]}`,
1046
+ );
1047
+ //
1048
+ // `>&word` is fd duplication only when `word` is a digit string or `-`
1049
+ // (`>&2`, `2>&1`, `>&-`); any other word is a FILE both stdout and stderr
1050
+ // are written to (`>&out.txt`, `>& out.txt`), so that form is matched as
1051
+ // its own operator and its target examined like any other.
1052
+ for (const redirect of scan.matchAll(/(>&|>{1,2}\|?)\s*([^\s&|;]+)/g)) {
1053
+ const target = redirect[2].replace(/^["']|["']$/g, "");
1054
+ if (redirect[1] === ">&" && /^(?:\d+|-)$/.test(target)) continue;
1055
+ if (!/^(\/dev\/null|nul)$/i.test(target)) {
237
1056
  return {
238
1057
  blocked: true,
239
1058
  reason: `writes to "${redirect[2]}" via shell redirection ("${redirect[0]}")`,
@@ -241,38 +1060,276 @@ export function classifyBashCommand(command) {
241
1060
  }
242
1061
  }
243
1062
 
244
- const { verb, sub, tokens } = parseSegment(segment);
245
- if (verb.length === 0) continue;
1063
+ const verdict = classifyTokens(
1064
+ stripClosingParens(words(segment).map(toWord)),
1065
+ );
1066
+ if (verdict !== undefined) return verdict;
1067
+ }
1068
+
1069
+ return { blocked: false };
1070
+ }
1071
+
1072
+ /**
1073
+ * Where a tool wrapper's own arguments start, or -1 when `verb`/`sub` is not
1074
+ * one: `npx`/`pnpx [-p pkg] <tool>`, `pnpm dlx`/`pnpm exec <tool>` and
1075
+ * `npm exec`/`npm x <tool>`.
1076
+ *
1077
+ * @param {string} verb
1078
+ * @param {string | undefined} sub
1079
+ * @param {number} subIndex
1080
+ * @returns {number}
1081
+ */
1082
+ function wrappedToolStart(verb, sub, subIndex) {
1083
+ if (verb === "npx" || verb === "pnpx") return 1;
1084
+ if (verb === "pnpm" && (sub === "dlx" || sub === "exec")) {
1085
+ return subIndex + 1;
1086
+ }
1087
+ if (verb === "npm" && (sub === "exec" || sub === "x")) return subIndex + 1;
1088
+ return -1;
1089
+ }
1090
+
1091
+ // `npm exec`'s own value flags on top of npm's global ones: the workspace to
1092
+ // run in and the package to fetch, neither of them the tool that runs.
1093
+ const NPM_EXEC_FLAGS_WITH_VALUE = new Set([
1094
+ ...FLAGS_WITH_VALUE.npm,
1095
+ "-w",
1096
+ "--workspace",
1097
+ "-p",
1098
+ "--package",
1099
+ ]);
1100
+
1101
+ /**
1102
+ * The value-taking flags of a tool wrapper (see wrappedToolStart), keyed by
1103
+ * its own verb -- `pnpm exec --filter x <tool>` must skip `x` by pnpm's
1104
+ * table, which npx's does not know.
1105
+ *
1106
+ * @param {string} verb
1107
+ * @returns {Set<string>}
1108
+ */
1109
+ function wrapperValueFlags(verb) {
1110
+ switch (verb) {
1111
+ case "pnpm":
1112
+ return FLAGS_WITH_VALUE.pnpm;
1113
+ case "npm":
1114
+ return NPM_EXEC_FLAGS_WITH_VALUE;
1115
+ default:
1116
+ return FLAGS_WITH_VALUE.npx;
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * The command-level checks for one tokenized segment (everything except the
1122
+ * redirect scan, which needs the raw segment text). A tool wrapper (see
1123
+ * wrappedToolStart) recurses on `<tool> ...`, so a wrapped fixer is caught the
1124
+ * same as a bare one; `inherited` carries the environment assignments stripped
1125
+ * ahead of the wrapper, which the wrapped tool still sees. A verb word (or,
1126
+ * for a verb with MUTATING_SUBCOMMANDS, a subcommand word) that cannot be
1127
+ * unquoted -- `$'...'` ANSI-C quoting or a quote that never closes -- blocks,
1128
+ * since the command it names is unknown.
1129
+ *
1130
+ * @param {Word[]} rawWords
1131
+ * @param {readonly string[]} [inherited]
1132
+ * @returns {{ blocked: true, reason: string } | undefined}
1133
+ */
1134
+ function classifyTokens(rawWords, inherited = []) {
1135
+ const parsed = parseTokens(rawWords);
1136
+ const { verb, sub, subIndex, words, tokens } = parsed;
1137
+ const assignments = [...inherited, ...parsed.assignments];
1138
+ if (tokens.length === 0) return undefined;
1139
+ const unreadable =
1140
+ words[0].value === undefined
1141
+ ? words[0]
1142
+ : subIndex !== -1 &&
1143
+ MUTATING_SUBCOMMANDS[verb] !== undefined &&
1144
+ words[subIndex].value === undefined
1145
+ ? words[subIndex]
1146
+ : undefined;
1147
+ if (unreadable !== undefined) {
1148
+ return {
1149
+ blocked: true,
1150
+ reason: `runs a command word (${unreadable.raw}) whose quoting could not be read`,
1151
+ };
1152
+ }
1153
+ if (verb.length === 0) return undefined;
1154
+
1155
+ if (SHELLS.has(verb)) {
1156
+ const commandIndex = shellCommandIndex(tokens);
1157
+ if (commandIndex !== -1) {
1158
+ return classifyNestedCommand(
1159
+ `${verb} -c`,
1160
+ words.slice(commandIndex, commandIndex + 1),
1161
+ );
1162
+ }
1163
+ }
1164
+
1165
+ if (MUTATING_VERBS.has(verb)) {
1166
+ return { blocked: true, reason: `runs "${verb}", a mutating command` };
1167
+ }
1168
+
1169
+ if (
1170
+ verb === "sed" &&
1171
+ tokens.some(
1172
+ (t) => t === "-i" || t.startsWith("-i") || t.startsWith("--in-place"),
1173
+ )
1174
+ ) {
1175
+ return { blocked: true, reason: `runs "sed -i" (in-place edit)` };
1176
+ }
246
1177
 
247
- if (MUTATING_VERBS.has(verb)) {
248
- return { blocked: true, reason: `runs "${verb}", a mutating command` };
1178
+ if (verb === "find") {
1179
+ if (tokens.includes("-delete")) {
1180
+ return {
1181
+ blocked: true,
1182
+ reason: `runs "find ... -delete", which mutates matched files`,
1183
+ };
249
1184
  }
1185
+ const execIndex = tokens.indexOf("-exec");
1186
+ if (execIndex !== -1) {
1187
+ const execVerb = baseName(tokens[execIndex + 1] ?? "");
1188
+ if (MUTATING_VERBS.has(execVerb)) {
1189
+ return {
1190
+ blocked: true,
1191
+ reason: `runs "find ... -exec ${execVerb}", which mutates matched files`,
1192
+ };
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ const writeFlag = fixerWriteFlag(verb, tokens);
1198
+ if (writeFlag !== undefined) {
1199
+ return {
1200
+ blocked: true,
1201
+ reason: `runs "${verb} ${writeFlag}", which rewrites files`,
1202
+ };
1203
+ }
250
1204
 
251
- if (
252
- verb === "sed" &&
253
- tokens.some((t) => t === "-i" || t.startsWith("-i"))
254
- ) {
255
- return { blocked: true, reason: `runs "sed -i" (in-place edit)` };
1205
+ // A wrapper that runs another tool -- judge `<tool> ...` itself, its
1206
+ // version pin stripped. Its own flags, a `--` separator included, are
1207
+ // skipped to reach the tool, except a shell-mode flag (WRAPPER_SHELL_FLAGS),
1208
+ // whose value is a shell command rather than a tool name. pnpm's shell-mode
1209
+ // flag is a boolean switch, not a value flag, so its command is the words
1210
+ // from the tool position on -- any `--filter x` between them is pnpm's own.
1211
+ const wrapperEnd = wrappedToolStart(verb, sub, subIndex);
1212
+ if (wrapperEnd !== -1) {
1213
+ const toolIndex = firstPositionalIndex(
1214
+ tokens,
1215
+ wrapperEnd,
1216
+ wrapperValueFlags(verb),
1217
+ );
1218
+ const flagsEnd = toolIndex === -1 ? tokens.length : toolIndex;
1219
+ for (let k = wrapperEnd; k < flagsEnd; k++) {
1220
+ const t = tokens[k];
1221
+ if (WRAPPER_SHELL_FLAGS[verb].has(t)) {
1222
+ const payloadStart = verb === "pnpm" ? flagsEnd : k + 1;
1223
+ return classifyNestedCommand(`${verb} ${t}`, words.slice(payloadStart));
1224
+ }
1225
+ if (t.startsWith("--call=") && WRAPPER_SHELL_FLAGS[verb].has("--call")) {
1226
+ const inline = t.slice("--call=".length);
1227
+ return classifyNestedCommand(`${verb} --call`, [
1228
+ {
1229
+ raw: inline,
1230
+ value: words[k].value === undefined ? undefined : inline,
1231
+ },
1232
+ ...words.slice(k + 1),
1233
+ ]);
1234
+ }
1235
+ }
1236
+ // pnpm also accepts its shell-mode flag BEFORE the subcommand (`pnpm -c
1237
+ // exec '<cmd>'`, `pnpm -r --shell-mode exec ...`): the words from the
1238
+ // tool position on are then the shell command.
1239
+ const preFlag =
1240
+ verb === "pnpm"
1241
+ ? tokens.slice(1, subIndex).find((t) => WRAPPER_SHELL_FLAGS.pnpm.has(t))
1242
+ : undefined;
1243
+ if (preFlag !== undefined) {
1244
+ return classifyNestedCommand(
1245
+ `pnpm ${preFlag} ${sub}`,
1246
+ toolIndex === -1 ? [] : words.slice(toolIndex),
1247
+ );
1248
+ }
1249
+ if (toolIndex === -1) return undefined;
1250
+ // Fail closed: a "tool" word holding whitespace once unquoted (`npx "rm
1251
+ // -rf src"`) is no package name -- judge it as the command string it is.
1252
+ if (/\s/.test(words[toolIndex].value ?? "")) {
1253
+ return classifyNestedCommand(verb, words.slice(toolIndex));
256
1254
  }
1255
+ const tool = unpinnedToolName(tokens[toolIndex]);
1256
+ return classifyTokens(
1257
+ [
1258
+ {
1259
+ raw: tool,
1260
+ value: words[toolIndex].value === undefined ? undefined : tool,
1261
+ },
1262
+ ...words.slice(toolIndex + 1),
1263
+ ],
1264
+ assignments,
1265
+ );
1266
+ }
1267
+ if (verb === "pnpm" && (sub === "eslint" || sub === "prettier")) {
1268
+ return classifyTokens(words.slice(subIndex), assignments);
1269
+ }
257
1270
 
258
- const mutatingSubs = MUTATING_SUBCOMMANDS[verb];
259
- if (
260
- mutatingSubs !== undefined &&
261
- sub !== undefined &&
262
- mutatingSubs.has(sub)
263
- ) {
1271
+ if (verb === "git") {
1272
+ const envVar = assignments.find((name) => GIT_EXEC_ENV.test(name));
1273
+ if (envVar !== undefined) {
1274
+ return {
1275
+ blocked: true,
1276
+ reason: `sets ${envVar}, which makes git run an arbitrary command`,
1277
+ };
1278
+ }
1279
+ const key = gitExecConfigKey(
1280
+ tokens.slice(1, subIndex === -1 ? undefined : subIndex),
1281
+ );
1282
+ if (key !== undefined) {
264
1283
  return {
265
1284
  blocked: true,
266
- reason: `runs "${verb} ${sub}", a mutating subcommand`,
1285
+ reason: `overrides git config "${key}", which runs an arbitrary command`,
267
1286
  };
268
1287
  }
269
1288
  }
270
1289
 
271
- return { blocked: false };
1290
+ const mutatingSubs = MUTATING_SUBCOMMANDS[verb];
1291
+ if (mutatingSubs === undefined || sub === undefined) return undefined;
1292
+ const args = tokens.slice(subIndex + 1);
1293
+
1294
+ if (verb === "git" && READ_ONLY_GIT_FORMS[sub]?.(args) === true) {
1295
+ return undefined;
1296
+ }
1297
+
1298
+ // `pnpm run <script>`/`npm run <script>`: the script name is what runs, so
1299
+ // classify it as the subcommand (`run format` blocks, `run format:check`
1300
+ // does not). Its own value flags (`run --filter x format`) are skipped.
1301
+ const scriptIndex =
1302
+ (verb === "pnpm" || verb === "npm") && sub === "run"
1303
+ ? firstPositionalIndex(tokens, subIndex + 1, FLAGS_WITH_VALUE[verb])
1304
+ : subIndex;
1305
+ if (scriptIndex === -1) return undefined;
1306
+ const script = tokens[scriptIndex];
1307
+ const shown = sub === script ? sub : `run ${script}`;
1308
+ if (mutatingSubs.has(script)) {
1309
+ return {
1310
+ blocked: true,
1311
+ reason: `runs "${verb} ${shown}", a mutating subcommand`,
1312
+ };
1313
+ }
1314
+
1315
+ // A lint/format script handed a fixer flag (`pnpm lint --fix`, `pnpm run
1316
+ // lint -- --fix`) forwards it to the tool, which then rewrites files.
1317
+ if (FIXER_SCRIPTS.has(script)) {
1318
+ const fixFlag = tokens
1319
+ .slice(scriptIndex + 1)
1320
+ .find((t) => FIXER_FLAG.test(t));
1321
+ if (fixFlag !== undefined) {
1322
+ return {
1323
+ blocked: true,
1324
+ reason: `runs "${verb} ${shown} ${fixFlag}", which rewrites files`,
1325
+ };
1326
+ }
1327
+ }
1328
+ return undefined;
272
1329
  }
273
1330
 
274
- // Deliberately inlined in every hook rather than shared: this pack's hook
275
- // budget is exactly its three hooks, so a helper module would cost a slot.
1331
+ // Deliberately inlined in every hook rather than shared, so each hook stays
1332
+ // one self-contained file.
276
1333
  // `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
277
1334
  // comparing them directly is false under any symlinked path and the body would
278
1335
  // never run -- exit 0.