@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.
- package/README.md +16 -8
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /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
|
|
22
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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}
|
|
117
|
-
* @
|
|
511
|
+
* @param {string[]} tokens
|
|
512
|
+
* @param {number} start
|
|
513
|
+
* @param {Set<string>} valueFlags
|
|
514
|
+
* @returns {number}
|
|
118
515
|
*/
|
|
119
|
-
function
|
|
120
|
-
|
|
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
|
-
|
|
134
|
-
}
|
|
135
|
-
if (t.startsWith("-")) {
|
|
522
|
+
} else if (t.startsWith("-")) {
|
|
136
523
|
j += 1; // a flag that doesn't consume a following value
|
|
137
|
-
|
|
524
|
+
} else {
|
|
525
|
+
return j;
|
|
138
526
|
}
|
|
139
|
-
sub = t;
|
|
140
|
-
break;
|
|
141
527
|
}
|
|
142
|
-
return
|
|
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;
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
|
229
|
-
//
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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: `
|
|
1285
|
+
reason: `overrides git config "${key}", which runs an arbitrary command`,
|
|
267
1286
|
};
|
|
268
1287
|
}
|
|
269
1288
|
}
|
|
270
1289
|
|
|
271
|
-
|
|
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
|
|
275
|
-
//
|
|
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.
|