@eslint-sh/eslint-plugin 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +19 -8
  2. package/dist/command-harvest/builtins/bash.d.ts +1 -1
  3. package/dist/command-harvest/builtins/bash.js +1 -1
  4. package/dist/command-harvest/builtins/bats.d.ts +2 -0
  5. package/dist/command-harvest/builtins/bats.js +3 -0
  6. package/dist/command-harvest/wrapper-table.d.ts +3 -19
  7. package/dist/command-harvest/wrapper-table.js +8 -38
  8. package/dist/configs/bash-3-2.d.ts +7 -6
  9. package/dist/configs/bash-3-2.js +5 -3
  10. package/dist/configs/posix-portable.d.ts +1 -1
  11. package/dist/configs/posix-portable.js +1 -0
  12. package/dist/declaration-conventions/read-declared-commands.d.ts +2 -2
  13. package/dist/declaration-conventions/read-declared-commands.js +2 -2
  14. package/dist/eslint-plugin-syntax.d.ts +4 -0
  15. package/dist/eslint-plugin.d.ts +86 -40
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.js +4 -2
  18. package/dist/language/shell-language-options.d.ts +18 -15
  19. package/dist/language/shell-language-options.js +33 -26
  20. package/dist/language/shell-language.d.ts +10 -9
  21. package/dist/language/shell-language.js +18 -35
  22. package/dist/language/shell-source-code.d.ts +17 -9
  23. package/dist/language/shell-source-code.js +53 -12
  24. package/dist/rules/bash-variable-semantics.d.ts +22 -0
  25. package/dist/rules/bash-variable-semantics.js +41 -0
  26. package/dist/rules/compat/create-compat-rule.js +1 -1
  27. package/dist/rules/compat/declaration-options.d.ts +2 -2
  28. package/dist/rules/compat/declaration-options.js +5 -2
  29. package/dist/rules/compat/no-append-all-redirect.js +1 -1
  30. package/dist/rules/compat/no-associative-arrays.js +2 -2
  31. package/dist/rules/compat/no-brace-sequence-padding-step.d.ts +15 -0
  32. package/dist/rules/compat/no-brace-sequence-padding-step.js +287 -0
  33. package/dist/rules/compat/no-case-attributes.js +3 -3
  34. package/dist/rules/compat/no-case-fallthrough.js +2 -2
  35. package/dist/rules/compat/no-case-modification.js +2 -2
  36. package/dist/rules/compat/no-coproc.js +1 -1
  37. package/dist/rules/compat/no-declare-g.js +3 -3
  38. package/dist/rules/compat/no-double-bracket.js +2 -2
  39. package/dist/rules/compat/no-epoch-variables.js +2 -2
  40. package/dist/rules/compat/no-fd-variable.js +1 -1
  41. package/dist/rules/compat/no-globstar.js +1 -1
  42. package/dist/rules/compat/no-lastpipe-semantics.js +2 -2
  43. package/dist/rules/compat/no-let.js +2 -1
  44. package/dist/rules/compat/no-mapfile.js +4 -2
  45. package/dist/rules/compat/no-nameref.js +3 -3
  46. package/dist/rules/compat/no-negative-array-index.js +39 -9
  47. package/dist/rules/compat/no-nounset-empty-array.js +15 -4
  48. package/dist/rules/compat/no-parameter-transformation.js +2 -2
  49. package/dist/rules/compat/no-pipe-all.js +1 -1
  50. package/dist/rules/compat/no-printf-time-format.js +4 -2
  51. package/dist/rules/compat/no-source.js +2 -1
  52. package/dist/rules/compat/no-test-v.js +4 -2
  53. package/dist/rules/compat/no-unicode-escape.js +2 -2
  54. package/dist/rules/compat/no-wait-n.js +4 -2
  55. package/dist/rules/compat/nounset-array-proof.d.ts +8 -0
  56. package/dist/rules/compat/nounset-array-proof.js +563 -0
  57. package/dist/rules/compat/posix-absent-command.d.ts +7 -8
  58. package/dist/rules/compat/posix-absent-command.js +17 -15
  59. package/dist/rules/compat/shopt-options.js +3 -1
  60. package/dist/rules/compat/version-gates.d.ts +10 -10
  61. package/dist/rules/compat/version-gates.js +21 -13
  62. package/dist/rules/create-wrapper-rule.js +12 -7
  63. package/dist/rules/index.js +2 -0
  64. package/dist/rules/no-dynamic-invocation.d.ts +0 -8
  65. package/dist/rules/no-dynamic-invocation.js +7 -15
  66. package/dist/rules/no-eval.js +3 -1
  67. package/dist/rules/no-masked-exit-status.js +15 -8
  68. package/dist/rules/no-secret-env-reads.d.ts +4 -3
  69. package/dist/rules/no-secret-env-reads.js +37 -14
  70. package/dist/rules/no-secret-flags.d.ts +2 -1
  71. package/dist/rules/no-secret-flags.js +7 -8
  72. package/dist/rules/no-unchecked-cd.js +3 -1
  73. package/dist/rules/no-undeclared-commands.d.ts +1 -15
  74. package/dist/rules/no-undeclared-commands.js +3 -15
  75. package/dist/rules/no-unused-declarations.d.ts +1 -18
  76. package/dist/rules/no-unused-declarations.js +9 -34
  77. package/dist/rules/prefer-local.js +4 -3
  78. package/dist/rules/quote-expansions.js +3 -28
  79. package/dist/rules/require-network-wrapper.d.ts +2 -17
  80. package/dist/rules/require-network-wrapper.js +33 -4
  81. package/dist/rules/require-tty-guard.js +30 -1
  82. package/dist/rules/resolve-declaration-convention.d.ts +5 -6
  83. package/dist/rules/resolve-declaration-convention.js +5 -6
  84. package/dist/rules/resolve-network-wrapper.d.ts +19 -0
  85. package/dist/rules/resolve-network-wrapper.js +57 -26
  86. package/dist/rules/resolve-transparent-wrappers.d.ts +43 -0
  87. package/dist/rules/resolve-transparent-wrappers.js +98 -0
  88. package/dist/rules/set-options.d.ts +2 -1
  89. package/dist/rules/set-options.js +17 -5
  90. package/dist/rules/shebang-interpreter.js +4 -2
  91. package/dist/rules/transparent-wrappers.d.ts +4 -3
  92. package/dist/rules/transparent-wrappers.js +5 -4
  93. package/dist/rules/user-pattern.d.ts +5 -3
  94. package/dist/rules/user-pattern.js +6 -4
  95. package/dist/syntax/index.d.ts +4 -0
  96. package/dist/syntax/index.js +4 -0
  97. package/docs/compat.md +90 -34
  98. package/docs/custom-rule-tutorial.md +9 -3
  99. package/docs/examples/no-listed-commands-rule.ts +2 -2
  100. package/docs/rules/README.md +38 -5
  101. package/docs/rules/_template.md +2 -2
  102. package/docs/rules/compat/no-append-all-redirect.md +12 -12
  103. package/docs/rules/compat/no-associative-arrays.md +14 -14
  104. package/docs/rules/compat/no-brace-sequence-padding-step.md +90 -0
  105. package/docs/rules/compat/no-case-attributes.md +13 -13
  106. package/docs/rules/compat/no-case-fallthrough.md +12 -12
  107. package/docs/rules/compat/no-case-modification.md +11 -11
  108. package/docs/rules/compat/no-coproc.md +13 -13
  109. package/docs/rules/compat/no-declare-g.md +13 -13
  110. package/docs/rules/compat/no-double-bracket.md +2 -2
  111. package/docs/rules/compat/no-epoch-variables.md +13 -13
  112. package/docs/rules/compat/no-fd-variable.md +12 -12
  113. package/docs/rules/compat/no-globstar.md +13 -13
  114. package/docs/rules/compat/no-lastpipe-semantics.md +13 -13
  115. package/docs/rules/compat/no-let.md +2 -2
  116. package/docs/rules/compat/no-mapfile.md +16 -16
  117. package/docs/rules/compat/no-nameref.md +13 -13
  118. package/docs/rules/compat/no-negative-array-index.md +14 -14
  119. package/docs/rules/compat/no-nounset-empty-array.md +72 -17
  120. package/docs/rules/compat/no-parameter-transformation.md +19 -19
  121. package/docs/rules/compat/no-pipe-all.md +13 -13
  122. package/docs/rules/compat/no-printf-time-format.md +13 -13
  123. package/docs/rules/compat/no-source.md +2 -2
  124. package/docs/rules/compat/no-test-v.md +12 -12
  125. package/docs/rules/compat/no-unicode-escape.md +13 -13
  126. package/docs/rules/compat/no-wait-n.md +13 -13
  127. package/docs/rules/no-dynamic-invocation.md +17 -12
  128. package/docs/rules/no-eval.md +6 -1
  129. package/docs/rules/no-masked-exit-status.md +4 -0
  130. package/docs/rules/no-secret-env-reads.md +34 -3
  131. package/docs/rules/no-secret-flags.md +19 -2
  132. package/docs/rules/no-unchecked-cd.md +9 -2
  133. package/docs/rules/no-undeclared-commands.md +16 -15
  134. package/docs/rules/no-unused-declarations.md +17 -20
  135. package/docs/rules/prefer-local.md +20 -2
  136. package/docs/rules/quote-expansions.md +8 -5
  137. package/docs/rules/require-network-wrapper.md +22 -17
  138. package/docs/rules/require-shebang.md +10 -4
  139. package/docs/rules/require-strict-mode.md +6 -0
  140. package/docs/rules/require-tty-guard.md +4 -1
  141. package/docs/shellcheck.md +10 -5
  142. package/docs/transparent-wrappers.md +97 -0
  143. package/docs/untrusted-code.md +440 -0
  144. package/docs/wrapper-rules.md +9 -0
  145. package/package.json +2 -2
  146. package/dist/language/end-of-input-marker.d.ts +0 -29
  147. package/dist/language/end-of-input-marker.js +0 -88
@@ -0,0 +1,440 @@
1
+ # Linting code you don't control
2
+
3
+ eslint-sh is often a policy gate: CI lints a pull request, an agent's output, or a vendored
4
+ repository whose author does not own the policy. In that setting, the author controls the scripts
5
+ under review and every other file in the checkout. This page covers what that lets them change, how
6
+ to run the lint from a directory outside those files, so that they can't change its configuration
7
+ or its exit code, and what a passing run still doesn't tell you:
8
+
9
+ - [What a script's comments can switch off](#what-a-scripts-comments-can-switch-off)
10
+ - [A policy config that ignores them](#a-policy-config-that-ignores-them)
11
+ - [Run ESLint from outside the checkout](#run-eslint-from-outside-the-checkout)
12
+ - [Declaration conventions that read files](#declaration-conventions-that-read-files)
13
+ - [Which files get linted](#which-files-get-linted)
14
+ - [What this does not cover](#what-this-does-not-cover)
15
+
16
+ Everything below was run against ESLint 10.2.0, the oldest version this plugin's peer range allows,
17
+ and against 10.7.0.
18
+
19
+ ## What a script's comments can switch off
20
+
21
+ ESLint's inline comments work in shell scripts the same way they do in JavaScript, with `#` as the
22
+ comment marker. With the default `linterOptions`, a script can silence any rule for itself, and
23
+ neither `sh/recommended` nor `sh/agent-authored` changes that. Each of these scripts lints with no
24
+ `sh/no-eval` report under `sh.configs.recommended`:
25
+
26
+ ```sh
27
+ #!/bin/bash
28
+ # eslint-disable-next-line sh/no-eval
29
+ eval "$x"
30
+ ```
31
+
32
+ | Comment | Effect |
33
+ | -------------------------------------------- | -------------------------------------------- |
34
+ | `# eslint-disable-next-line sh/no-eval` | Silences the rule on the next line. |
35
+ | `eval "$x" # eslint-disable-line sh/no-eval` | Silences the rule on its own line. |
36
+ | `# eslint-disable` … `# eslint-enable` | Silences every rule between the two. |
37
+ | `# eslint sh/no-eval: off` | Turns the rule off for the whole file. |
38
+
39
+ The `eslint` prefix is the default `languageOptions.directivePrefix`. A config that sets it, for
40
+ example to `shellcheck-sh`, makes `# shellcheck-sh-disable-next-line sh/no-eval` the working form
41
+ and turns `# eslint-disable-next-line` into an ordinary comment. The prefix is chosen by the config,
42
+ not by the script, and it does not stop directives, it only renames them.
43
+
44
+ ## A policy config that ignores them
45
+
46
+ Set `linterOptions.noInlineConfig` in the config you lint with. The `globalIgnores` line and the
47
+ `checkout/` in `files` belong to [the recipe](#run-eslint-from-outside-the-checkout), which explains
48
+ them.
49
+
50
+ ```js
51
+ // policy/eslint.config.mjs
52
+ import { defineConfig, globalIgnores } from 'eslint/config';
53
+ import sh from '@eslint-sh/eslint-plugin';
54
+
55
+ export default defineConfig([
56
+ globalIgnores(['!**/node_modules/', 'checkout/.git/']),
57
+ {
58
+ files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
59
+ extends: [sh.configs.recommended],
60
+ linterOptions: { noInlineConfig: true },
61
+ },
62
+ ]);
63
+ ```
64
+
65
+ `noInlineConfig: true` makes ESLint ignore every comment in the table above, whatever the
66
+ `directivePrefix`. The rule reports as if the comment were not there, and ESLint adds a warning with
67
+ no rule id at the comment itself. For the script above, with its other report:
68
+
69
+ ```text
70
+ 1:1 error Enable strict mode with `set -euo pipefail` within the first 5 statements; not enabled: errexit, nounset, pipefail sh/require-strict-mode
71
+ 2:1 warning '# eslint-disable-next-line sh/no-eval' has no effect because you have 'noInlineConfig' setting in your config
72
+ 3:1 error `eval` runs text as shell code at run time, so the code it executes cannot be statically reviewed. Write the commands directly instead sh/no-eval
73
+ ```
74
+
75
+ The plugin returns its directives to ESLint through the language's `getDisableDirectives` and
76
+ `getInlineConfigNodes` hooks, and ESLint applies `noInlineConfig` to them as it does to
77
+ JavaScript comments. Under `--max-warnings 0`, that warning fails the
78
+ run on its own: a contributed script that contains any directive fails the gate, even one that would
79
+ have suppressed nothing. If you would rather let those through, run without `--max-warnings 0` and
80
+ read the warnings, but then no other warning fails the run either (see the
81
+ [continuous integration exit codes](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration)).
82
+
83
+ **If you accept directives instead.** `linterOptions.reportUnusedDisableDirectives` sets the
84
+ severity of a disable comment that suppressed nothing. ESLint 10's default is `"warn"`; `"error"`
85
+ fails the run on one:
86
+
87
+ ```js
88
+ // policy/eslint.config.mjs, directives allowed
89
+ import { defineConfig, globalIgnores } from 'eslint/config';
90
+ import sh from '@eslint-sh/eslint-plugin';
91
+
92
+ export default defineConfig([
93
+ globalIgnores(['!**/node_modules/', 'checkout/.git/']),
94
+ {
95
+ files: ['**/*.sh', '**/*.bash', 'checkout/**/bin/?*'],
96
+ extends: [sh.configs.recommended],
97
+ linterOptions: { reportUnusedDisableDirectives: 'error' },
98
+ },
99
+ ]);
100
+ ```
101
+
102
+ This keeps stale directives out of the tree, but it is not a gate: the `# eslint-disable-next-line
103
+ sh/no-eval` above still silences `sh/no-eval`, because it did suppress something. Setting both
104
+ options is allowed, but `reportUnusedDisableDirectives` then reports nothing, because
105
+ `noInlineConfig` has already discarded every directive.
106
+
107
+ ## Run ESLint from outside the checkout
108
+
109
+ `linterOptions` only helps if the contributor can't replace the config, ESLint or the plugin, and
110
+ can't change the result some other way. ESLint finds several of its inputs in the directory it runs
111
+ in, and Node looks through that directory's parents for packages. When that directory is the
112
+ checkout, every one of these belongs to the contributor:
113
+
114
+ - **The config.** `eslint .` without `--config` imports the `eslint.config.mjs` in the directory it
115
+ runs in, as JavaScript, with your job's environment variables. ESLint 10 also loads an
116
+ `eslint.config.*` from a subdirectory for the files under it, so a nested config in the checkout
117
+ runs, and replaces your policy for its directory, even if your config sits at the root.
118
+ - **Where the config's imports resolve.** `import sh from '@eslint-sh/eslint-plugin'` resolves
119
+ from the config file's own location. A copy of your config placed inside the checkout imports
120
+ the checkout's `node_modules`.
121
+ - **Which ESLint runs.** `npx eslint` in the checkout runs the checkout's
122
+ `node_modules/.bin/eslint`.
123
+ - **The formatter.** ESLint resolves a formatter name, including the default `stylish`, as the
124
+ package `eslint-formatter-stylish` from the directory it runs in and then from each of its parents,
125
+ with or without `--config`. That package is code in your job. It can print what it likes and set
126
+ the exit code.
127
+ - **The suppressions file.** ESLint applies `eslint-suppressions.json` from the directory it runs in
128
+ whenever that file exists, with no flag, and neither `--config` nor `noInlineConfig` affects it.
129
+ An error listed in the file is hidden, and the run exits 0.
130
+ - **The cache file.** With `--cache`, ESLint reads `.eslintcache` from the directory it runs in and
131
+ reuses the results recorded there. The default `--cache-strategy metadata` trusts a recorded size
132
+ and modification time. `--cache-strategy content` doesn't help either, because the hash it
133
+ compares against is in the same file.
134
+ - **Your job's own relative paths.** A relative entry in `PATH`, or a relative file in
135
+ `NODE_OPTIONS`, resolves against the directory ESLint runs in, so either can run a file from the
136
+ checkout. A version manager may read its settings from there too: `nodenv` runs ESLint under the
137
+ Node version named in a `.node-version` in the checkout or any parent. Other version managers were
138
+ not tested.
139
+
140
+ Installing the checkout is a separate hazard: `npm ci` and `npm install` run the package's lifecycle
141
+ scripts, such as `preinstall`. The lint needs none of the checkout's dependencies, so don't install
142
+ them.
143
+
144
+ The recipe is to run ESLint somewhere else and pass the checkout as an argument. Keep the policy
145
+ where the contributor can't change it in the same change, such as a separate repository or a path
146
+ your CI provides, with `eslint` and `@eslint-sh/eslint-plugin` installed there from its own lockfile.
147
+ If `policy` is a link to such a path, the rule below applies to the parents of its real location
148
+ too, and the guards don't look there.
149
+ Put it beside the checkout, in a directory your job creates and the contributor can't write to.
150
+ Neither that directory nor any of its parents may be inside the contributor's files: a CI job often
151
+ starts in a clone of the repository under review, so don't create it there. The recipe refuses a
152
+ `node_modules` in a parent, but it has no guard for the rest of that rule, such as a `.node-version`
153
+ there.
154
+
155
+ ```text
156
+ job-directory/
157
+ policy/ eslint.config.mjs and its own node_modules
158
+ checkout/ the code under review
159
+ ```
160
+
161
+ Create `checkout` yourself, so that nothing of the contributor's lands beside it. From a repository:
162
+
163
+ ```sh
164
+ git clone URL checkout
165
+ ```
166
+
167
+ From a tarball, keep the input archive outside `job-directory` (here, `../x.tar`) and unpack only
168
+ into `checkout`. Keep every path as archived: the policy covers files directly inside any `bin/`
169
+ directory under `checkout`, so wrappers and siblings need no special handling.
170
+
171
+ ```sh
172
+ [ ! -e checkout ] && [ ! -L checkout ] || { printf 'checkout already exists\n' >&2; exit 1; }
173
+ mkdir checkout && tar -xf ../x.tar -C checkout || exit 1
174
+ ```
175
+
176
+ Stop the job if this fails: a failed `tar` can leave only some of the files. Don't strip path
177
+ components or move files out of a wrapper; stripping can silently discard top-level files.
178
+
179
+ Then, from `job-directory`, not from inside the checkout:
180
+
181
+ ```sh
182
+ [ "$(ls -A | wc -l)" -eq 2 ] && [ -d policy ] && [ -d checkout ] || {
183
+ printf 'the job directory must hold only checkout and policy\n' >&2
184
+ exit 1
185
+ }
186
+ dir=$(pwd -P)
187
+ while :; do
188
+ if [ -e "$dir/node_modules" ]; then
189
+ printf 'node_modules in or above the job directory: %s\n' "$dir" >&2
190
+ exit 1
191
+ fi
192
+ [ "$dir" != / ] || break
193
+ dir=$(dirname "$dir")
194
+ done
195
+ policy/node_modules/.bin/eslint \
196
+ --config policy/eslint.config.mjs \
197
+ --max-warnings 0 \
198
+ checkout
199
+ ```
200
+
201
+ - The working directory is what closes the list above. `eslint-suppressions.json`, `.eslintcache`
202
+ and any relative path in your job's environment resolve in `job-directory`, the formatter name
203
+ resolves there and in its parents, and the checkout's copies are never consulted.
204
+ - The first guard refuses anything else in `job-directory`, because ESLint reads its inputs from
205
+ there: a tarball unpacked here rather than as above can leave an `eslint-suppressions.json` beside
206
+ the checkout. It can't catch such a tarball writing into `policy/`, which only unpacking elsewhere
207
+ prevents.
208
+ - The second guard refuses a `node_modules` in `job-directory` or any parent, because Node looks
209
+ there for the formatter and for a package ESLint's own dependencies ask for on every run, whatever
210
+ `--format` says, and a package found there runs in your job and can set the exit code. If it
211
+ refuses a `node_modules` of your own, such as one in your home directory, make the job directory
212
+ with `mktemp -d` instead.
213
+ - `--config` loads only the policy, and its imports resolve from `policy/node_modules`.
214
+ - The checkout has to be under the working directory. With `--config`, ESLint matches the config's
215
+ `files` and `ignores` against the directory it runs in and treats anything outside it as ignored:
216
+ running from inside `policy/` with `../checkout`, or from elsewhere with an absolute path, exits 2
217
+ with `all of the files matching the glob pattern ... are ignored`. The policy's
218
+ `checkout/**/bin/?*` matches files directly in a `bin` at any depth under `checkout`.
219
+ - `!**/node_modules/` in `globalIgnores` turns off ESLint's default ignore of `**/node_modules/`.
220
+ Without it, a script under any directory named `node_modules`, at any depth, is not linted, and
221
+ the contributor can commit a directory of that name anywhere. A wrapper named `node_modules`
222
+ or starting with a dot is covered too. `node_modules/.bin` is not a directory named `bin`:
223
+ regular files there need a `.sh` or `.bash` extension to match; its usual symlinks fail the check.
224
+ - `checkout/.git/` in `globalIgnores` keeps the lint out of the clone's repository data, because
225
+ ESLint's default ignores only a `.git` in the directory it runs in, and a branch called
226
+ `fix/deploy.sh` is a file there that matches `files`.
227
+ - `--max-warnings 0` makes warnings, including the "has no effect" warnings above, fail the run.
228
+ The exit codes are the ones in the
229
+ [continuous integration table](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-sh/README.md#continuous-integration).
230
+ - Leave `--cache` off. The recipe needs no cache, and if you add one, it must not come from the
231
+ checkout or from anything else the contributor could write.
232
+
233
+ To find what was left, this command was run with every `fs` call, module load and child process
234
+ logged, against a checkout holding each ESLint input named on this page and a marker-writing copy
235
+ of every package in the policy's install. Under the checkout, ESLint only listed directories and read the
236
+ files it lints. It loaded no module from there and started no process. That is a result for the
237
+ ESLint versions above, not a promise from ESLint; this page's tests repeat the trace on the version
238
+ the repository uses.
239
+
240
+ The workflow that runs this command is part of the gate too: keep it, like the policy, where the
241
+ contributor's change can't edit it. Nothing of the contributor's should have run on the machine
242
+ before it, either: an install, build or test run in the same job can change any file the job can
243
+ write, including the policy.
244
+
245
+ ## Declaration conventions that read files
246
+
247
+ A [`DeclarationConvention`](./declaration-conventions.md) is code in your config, and one that reads
248
+ files reads whatever path it is given. When linting code you don't control, read only paths you
249
+ control: a file built from the policy config's own location (`new URL('./commands.txt',
250
+ import.meta.url)`), or data in the config itself. Don't read a file located from the linted script's
251
+ path, such as the per-script sidecars in
252
+ [declarations outside the script](./declaration-conventions.md#declarations-outside-the-script) or
253
+ `scriptSidecarCommands()` in the repository's
254
+ [container-entrypoint example pack](https://github.com/mike-north/eslint-sh/tree/main/examples/container-entrypoint-pack):
255
+ in a checkout under review, every file next to a script belongs to the contributor.
256
+
257
+ ## Which files get linted
258
+
259
+ ESLint lints the files the policy's `files` patterns match, and the contributor chooses file names.
260
+ With the policy above, a checkout with a clean `ok.sh` and these scripts, each starting `#!/bin/bash`
261
+ and containing `eval "$x"`, exits 0 with no report from the recipe:
262
+
263
+ - `ci/run` and `deploy`: no extension, outside any `bin/`.
264
+ - `build.SH`: the patterns are case-sensitive.
265
+ - `x.zsh`: the extension is not in the patterns.
266
+ - `script.sh.txt`: the pattern needs the name to end in `.sh`.
267
+
268
+ Naming a script differently is enough to avoid being linted. Widening the patterns does not close
269
+ this, because any list of names is a list the contributor can step around. What you can do is
270
+ check the checkout for files that start with a shell shebang and that the patterns do not match,
271
+ and fail the job when there are any. Run this from `job-directory`, before the lint, from a workflow
272
+ the contributor can't edit:
273
+
274
+ ```sh
275
+ [ -d checkout ] && [ ! -L checkout ] || { printf 'no checkout directory here\n' >&2; exit 2; }
276
+ status=0
277
+ while IFS= read -r -d '' file; do
278
+ if [ -L "$file" ] || [ ! -f "$file" ] || [ ! -r "$file" ]; then
279
+ printf 'symlink, special or unreadable file: %q\n' "$file" >&2
280
+ status=1
281
+ continue
282
+ fi
283
+ case "$file" in
284
+ *.sh | *.bash) continue ;;
285
+ esac
286
+ # The policy covers direct children of every bin, regardless of wrappers.
287
+ case "${file%/*}" in
288
+ */bin) continue ;;
289
+ esac
290
+ if head -c 1024 "$file" | head -n 1 | LC_ALL=C grep -Eiq '^#!.*sh'; then
291
+ printf 'not linted, but has a shell shebang: %q\n' "$file" >&2
292
+ status=1
293
+ fi
294
+ done < <(find checkout -path checkout/.git -type d -prune -o ! -type d -print0)
295
+ [ "$status" -eq 0 ] || exit "$status"
296
+ ```
297
+
298
+ This needs `bash` (it uses `read -d` and process substitution). It exits non-zero when it finds
299
+ something and otherwise falls through, so it can sit above the recipe in one script or run as its
300
+ own step. Its first line exits 2 without a real `checkout` directory, because a failing `find`
301
+ wouldn't fail the check, and a check run from the wrong directory would pass every time. The first
302
+ `case` covers the extensions; the second checks the parent directory, so it covers direct children
303
+ of every `bin/` without accidentally accepting `bin/sub/run`. Keep these checks in sync with the
304
+ policy's `files`.
305
+
306
+ To rename `checkout`, change it everywhere. Left behind in the guards or the recipe's argument, the
307
+ old name stops the run, and in `globalIgnores` it can only add failures. The policy's `files`, the
308
+ `case` and the `find` must change together: if only `files` is left behind, `.sh` files are still
309
+ linted, so nothing looks wrong, but `bin/` silently isn't.
310
+
311
+ The check looks everywhere in the checkout except a top-level `.git` directory, including under
312
+ `node_modules`. The lint skips that directory too, and only a tarball can put files of its own there,
313
+ so don't run anything from it. The check names every file in the example above except `ok.sh`, and it
314
+ fails on any symlink, special file or file it can't read (see [symlinks](#what-this-does-not-cover)),
315
+ so an installed `node_modules`, whose `.bin` holds symlinks, fails it too. A directory it can't read
316
+ only makes `find` print an error, and the recipe then exits 2 on it. It flags any first line starting `#!` that contains `sh` in any
317
+ case, so `#!/usr/local/bin/publish` fails it as well. When it fails, either fix the name or extend
318
+ the policy's `files` to cover the file.
319
+
320
+ It uses `find` where `git ls-files` would be shorter, because git reads the checkout's own repository
321
+ configuration, which a checkout you did not clone yourself can set, and that configuration can run
322
+ commands. Don't run git in such a checkout, including to list changed files.
323
+
324
+ It is not a complete answer. A script with no shebang, outside every `bin/` and without a `.sh` or
325
+ `.bash` extension, passes both the lint and this check, even if something runs it as `sh deploy` or
326
+ `source`s it. So does one whose
327
+ shebang doesn't contain `sh`, such as a wrapper with another name. The check narrows what a
328
+ contributor can hide; it does not make the set of linted files equal to the set of scripts the
329
+ project runs.
330
+
331
+ The lint sees the files on disk, not the commit, so run it on a file system that keeps every name
332
+ apart, such as Linux's: on macOS's default, a commit with both `A.sh` and `a.sh` clones to one file,
333
+ and only that file is linted. The same goes for names that differ only in Unicode normal form,
334
+ which collide on APFS even on a case-sensitive volume. Lint what you will run, too. A plain clone leaves a submodule's directory empty, so use
335
+ `git clone --recurse-submodules` if anything later fetches submodules. And the contributor's
336
+ `.gitattributes` can mark a file `export-ignore`, which keeps it out of a `git archive` tarball, so
337
+ a tarball can lint clean while a clone of the same commit holds the file.
338
+
339
+ An honest repository can also fail the recipe. ESLint lints `*.js`, `*.mjs` and `*.cjs` by default,
340
+ whatever the policy's `files` say, and `checkout/**/bin/?*` covers everything directly in any `bin/`
341
+ under `checkout`, including vendored binaries. Those files are linted as shell, and can fail with
342
+ parse errors or shell policy findings. A JavaScript file with a disable comment, a JavaScript file
343
+ that ESLint's default parser rejects, or a
344
+ Python script in `bin/` each exits 1. So does any JavaScript file directly in `bin/`, however valid,
345
+ with a message telling you to add a `files` entry that is already there, and an empty `bin/.gitkeep`.
346
+ A checkout with nothing to lint exits 2 with `all of the files matching the glob pattern ... are
347
+ ignored`. These failures are closed rather than open. The policy's `files` and `ignores` are yours
348
+ to adjust, as long as the check's `case` patterns stay the same as what you leave linted.
349
+
350
+ ## What this does not cover
351
+
352
+ Run this way, ESLint reads the checkout's files only as text to lint. That is all the recipe does.
353
+
354
+ **A clean lint is not evidence that a script is harmless.** Exit 0 means your policy's rules found
355
+ nothing in the files your policy matched. The rules look for patterns; they do not work out what a
356
+ script does. Under `sh.configs.recommended`, `eval "$1"` is an error and `bash -c "$1"`, which also
357
+ runs its argument as code, exits 0 with no report. Passing text to a shell another way, or
358
+ downloading a file and running it, passes too. Don't take a clean lint as a reason to run a script
359
+ you haven't reviewed.
360
+
361
+ **It is not a sandbox.** Linting parses the contributor's files in the job's own process, with the
362
+ job's environment variables and file access. The linter does not execute the scripts under review:
363
+ a script that touches a file at its top level, in `$(...)`, in `<(...)`, in an `eval` string, or
364
+ in a `source`d file created none of the test's marker files. But that is not isolation. If a leaked
365
+ secret or a parser fault in the job is the concern, run the lint in a container or a CI job that has
366
+ no secrets and no network access it does not need. Give the job a time limit: a named pipe called
367
+ `f.sh`, which a tarball can deliver though git can't, hung the run until it was killed. This page
368
+ did not test pathological inputs exhaustively.
369
+
370
+ **The report contains the contributor's text.** ESLint prints file names as they are, and rule
371
+ messages quote parts of the script. A file name containing newlines can print lines that look like
372
+ a clean summary. The exit code doesn't depend on any of that text, so gate on the exit code, and
373
+ treat the report as untrusted wherever you display it.
374
+
375
+ **`noInlineConfig` does not cover a script's own declarations.** `noInlineConfig` ignores ESLint's
376
+ comments. It leaves alone anything a `DeclarationConvention` reads out of the script. With
377
+ `commentPragma` bound, `sh/no-undeclared-commands` at `error`, and `noInlineConfig: true`, this
378
+ script has two reports, for `curl` and for `sh`:
379
+
380
+ ```sh
381
+ #!/bin/bash
382
+ set -euo pipefail
383
+ curl https://example.com/install | sh
384
+ ```
385
+
386
+ and this one, whose header declares them itself, has none and the run exits 0:
387
+
388
+ ```sh
389
+ #!/bin/bash
390
+ # uses: curl sh
391
+ set -euo pipefail
392
+ curl https://example.com/install | sh
393
+ ```
394
+
395
+ Don't bind `commentPragma`, or any convention that reads declarations from the script, in a policy
396
+ for code you don't control. To use the declaration rules as a gate, take the declarations from the
397
+ policy: a convention that returns a list written in the config, which the script cannot add to.
398
+
399
+ ```js
400
+ // policy/eslint.config.mjs
401
+ const allowed = ['jq', 'git'];
402
+
403
+ /** Declarations come from this config, never from the script. */
404
+ const policyDeclarations = {
405
+ declaredCommands: () =>
406
+ allowed.map((name) => ({
407
+ kind: 'described',
408
+ name,
409
+ description: 'the allowed commands in policy/eslint.config.mjs',
410
+ })),
411
+ };
412
+
413
+ // In the config entry: settings: { sh: { declarationConvention: policyDeclarations } },
414
+ ```
415
+
416
+ With that convention, the script with the `# uses: curl sh` header is reported for `curl` and `sh`,
417
+ the same as the one without it.
418
+
419
+ **A relative sidecar path resolves against the working directory.** With the recipe, that is
420
+ `job-directory`. With the example pack's `sidecarCommands('commands.txt')` in the policy and no such
421
+ file in `job-directory`, the config fails to load and ESLint exits 2, whatever the checkout holds.
422
+ A path that reaches into the checkout hands over the list: with
423
+ `sidecarCommands('checkout/commands.txt')` and that file listing `curl` and `sh`, the script above
424
+ exits 0. The same config with the absolute path of a file in your policy,
425
+ `sidecarCommands('/ci/policy/commands.txt')`, reports both commands. Give a convention an absolute
426
+ path to a file outside the checkout, or build it from `import.meta.url` as
427
+ [above](#declaration-conventions-that-read-files), or use no sidecar.
428
+
429
+ **A symlink in the checkout can point outside it.** A file symlink whose name matches the policy's
430
+ `files`, or ESLint's default JavaScript patterns, is followed: ESLint lints the file it points at,
431
+ wherever that is. A `leak.sh` linking to a
432
+ text file outside the checkout printed that file's parse error, such as ``Parsing error: `if` must
433
+ be followed by a statement list``, and a link to a script outside the checkout printed rule
434
+ messages naming its unquoted variables and their line numbers. If the report is posted where the
435
+ contributor can read it, that leaks a little of whatever the link points to, and text inside an
436
+ unquoted `$(...)` prints verbatim. Other symlinked directories are not followed: files behind a link
437
+ to a directory were not linted or reported. A link to a directory or to nothing stops the run with
438
+ exit 2 if its own name matches `files` or ESLint's default JavaScript patterns, and is skipped
439
+ otherwise. The check above rejects every symlink under the checkout, including one named
440
+ `node_modules` or `.git`, which closes the file case when it runs before the lint.
@@ -9,6 +9,10 @@ Which commands need a wrapper, and which wrapper, is your project's policy. This
9
9
  wrapper name. You bind both, either in a rule pack with `createWrapperRule` or in your config
10
10
  with [`sh/require-network-wrapper`](./rules/require-network-wrapper.md)'s `wrapper` option.
11
11
 
12
+ Project wrapper facts are shared through
13
+ `settings: { sh: { transparentWrappers: ['with-retry'] } }`. This rule follows
14
+ those wrappers and the shared network wrapper; see [shared transparent wrappers](./transparent-wrappers.md).
15
+
12
16
  ## `createWrapperRule`
13
17
 
14
18
  ```ts
@@ -204,3 +208,8 @@ command runs after its own options, located the way a wrapped command is located
204
208
  `noArgFlags` and `argFlags` are skipped, and an option in `stopsChainFlags` means no subcommand
205
209
  runs. The message names the command with its subcommand: `` `git fetch` must be run through
206
210
  `retry`. ``
211
+
212
+ A `WrapperSpec` may set `shellBuiltin: true` for a wrapper that runs inside the current shell.
213
+ Such entries match exact names: an executable such as `/tmp/command` is not the `command`
214
+ builtin and its argument words remain opaque. Omitted or false retains utility-wrapper path
215
+ matching. The same boolean field is accepted in the network-wrapper option and setting.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@eslint-sh/eslint-plugin",
3
3
  "description": "Shell script linting for ESLint, with built-in policy rules, shared presets, and custom rules over a typed shell AST",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "author": "Mike North <michael.l.north@gmail.com>",
6
6
  "dependencies": {
7
7
  "@eslint/core": "^1.2.1",
8
8
  "@eslint/plugin-kit": "^0.7.2",
9
- "sh-ast": "^0.7.0"
9
+ "sh-ast": "^0.9.0"
10
10
  },
11
11
  "devDependencies": {
12
12
  "eslint": "^10.7.0"
@@ -1,29 +0,0 @@
1
- /**
2
- * Detects a source whose U+0080 made the parser silently drop text.
3
- *
4
- * mvdan/sh's reader uses U+0080 (`utf8.RuneSelf`) as its internal end-of-input marker. In some
5
- * positions the parser reads it as an ordinary character and returns the whole tree; in others
6
- * it stops there and returns a tree for only the text before it, with no error. A linter that
7
- * accepted the second kind would skip every rule on the lost text, so those sources must be
8
- * refused while the first kind keeps linting.
9
- *
10
- * The two are told apart by asking the parser what it does with the same source when U+0080 is
11
- * an ordinary character: the parse is complete exactly when the printed tree is the same as the
12
- * printed tree of the source with every U+0080 swapped for `X`. Text the parser dropped is
13
- * missing from the first print and present in the second. Nothing is inferred from tree
14
- * extents, so a trailing comment or heredoc body cannot make a truncated file look complete.
15
- */
16
- import { type ParseOptions } from 'sh-ast';
17
- /**
18
- * The UTF-16 index of the first U+0080 whose presence makes the parser drop text, or
19
- * `undefined` when `text` holds none or the parser handles every one of them completely.
20
- *
21
- * A source the parser rejects with an error is also `undefined`: that is a fatal parse error the
22
- * caller reports through its normal path, not a silent loss. Every other doubt is a refusal:
23
- * a source that parses but prints differently from its swapped counterpart is refused, and so is
24
- * one whose swapped counterpart no longer parses, so an undecidable position is never accepted.
25
- *
26
- * @param text - The source about to be parsed.
27
- * @param options - The parse options the caller will use, so the dialect matches.
28
- */
29
- export declare function firstDroppedTextIndex(text: string, options: ParseOptions): number | undefined;
@@ -1,88 +0,0 @@
1
- /**
2
- * Detects a source whose U+0080 made the parser silently drop text.
3
- *
4
- * mvdan/sh's reader uses U+0080 (`utf8.RuneSelf`) as its internal end-of-input marker. In some
5
- * positions the parser reads it as an ordinary character and returns the whole tree; in others
6
- * it stops there and returns a tree for only the text before it, with no error. A linter that
7
- * accepted the second kind would skip every rule on the lost text, so those sources must be
8
- * refused while the first kind keeps linting.
9
- *
10
- * The two are told apart by asking the parser what it does with the same source when U+0080 is
11
- * an ordinary character: the parse is complete exactly when the printed tree is the same as the
12
- * printed tree of the source with every U+0080 swapped for `X`. Text the parser dropped is
13
- * missing from the first print and present in the second. Nothing is inferred from tree
14
- * extents, so a trailing comment or heredoc body cannot make a truncated file look complete.
15
- */
16
- import { parseSync, printSync } from 'sh-ast';
17
- const END_OF_INPUT_MARKER = '\u0080';
18
- /** An ordinary one-UTF-16-unit word character, so offsets are the same in both parses. */
19
- const ORDINARY_CHARACTER = 'X';
20
- /** What the parser makes of a source: the printed tree, or `ERROR` when it does not parse. */
21
- const ERROR = Symbol('parse error');
22
- /**
23
- * Prints `text` the way the parser sees it. Every U+0080 in the result is swapped for the
24
- * ordinary character so the print of the original can be compared with the print of the
25
- * swapped source.
26
- */
27
- function printed(text, options) {
28
- try {
29
- const output = printSync(parseSync(text, options));
30
- const decoded = typeof output === 'string' ? output : new TextDecoder().decode(output);
31
- return decoded.replaceAll(END_OF_INPUT_MARKER, ORDINARY_CHARACTER);
32
- }
33
- catch {
34
- return ERROR;
35
- }
36
- }
37
- /**
38
- * The UTF-16 index of the first U+0080 whose presence makes the parser drop text, or
39
- * `undefined` when `text` holds none or the parser handles every one of them completely.
40
- *
41
- * A source the parser rejects with an error is also `undefined`: that is a fatal parse error the
42
- * caller reports through its normal path, not a silent loss. Every other doubt is a refusal:
43
- * a source that parses but prints differently from its swapped counterpart is refused, and so is
44
- * one whose swapped counterpart no longer parses, so an undecidable position is never accepted.
45
- *
46
- * @param text - The source about to be parsed.
47
- * @param options - The parse options the caller will use, so the dialect matches.
48
- */
49
- export function firstDroppedTextIndex(text, options) {
50
- const indices = [];
51
- for (let at = text.indexOf(END_OF_INPUT_MARKER); at !== -1; at = text.indexOf(END_OF_INPUT_MARKER, at + 1)) {
52
- indices.push(at);
53
- }
54
- if (indices.length === 0)
55
- return undefined;
56
- const ordinary = text.replaceAll(END_OF_INPUT_MARKER, ORDINARY_CHARACTER);
57
- const expected = printed(ordinary, options);
58
- const actual = printed(text, options);
59
- if (actual === ERROR)
60
- return undefined;
61
- if (expected !== ERROR && actual === expected)
62
- return undefined;
63
- /** Whether keeping only the first `count` U+0080 characters (the rest swapped) still differs. */
64
- const dropsText = (count) => {
65
- let candidate = '';
66
- let from = 0;
67
- for (const index of indices.slice(0, count)) {
68
- candidate += ordinary.slice(from, index) + END_OF_INPUT_MARKER;
69
- from = index + 1;
70
- }
71
- candidate += ordinary.slice(from);
72
- const result = printed(candidate, options);
73
- return result === ERROR || expected === ERROR || result !== expected;
74
- };
75
- // The full source differs, so the smallest differing prefix of U+0080 characters exists; the
76
- // search assumes that keeping more of them never repairs the parse, and only ever lands on a
77
- // count that was itself observed to differ.
78
- let low = 1;
79
- let high = indices.length;
80
- while (low < high) {
81
- const middle = (low + high) >> 1;
82
- if (dropsText(middle))
83
- high = middle;
84
- else
85
- low = middle + 1;
86
- }
87
- return indices[high - 1];
88
- }