@codyswann/lisa 4.28.2 → 4.28.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/dist/codex/scripts/block-no-verify.sh +302 -97
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +2 -1
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-no-verify.agy.sh +302 -97
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +302 -97
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-no-verify.agy.sh +302 -97
package/package.json
CHANGED
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
"zod-validation-error": "^4.0.0"
|
|
163
163
|
},
|
|
164
164
|
"name": "@codyswann/lisa",
|
|
165
|
-
"version": "4.28.
|
|
165
|
+
"version": "4.28.3",
|
|
166
166
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
167
167
|
"main": "dist/index.js",
|
|
168
168
|
"exports": {
|
|
@@ -307,6 +307,6 @@
|
|
|
307
307
|
"test": "tests"
|
|
308
308
|
},
|
|
309
309
|
"types": "./dist/index.d.ts",
|
|
310
|
-
"lisaReleaseCommit": "
|
|
311
|
-
"gitHead": "
|
|
310
|
+
"lisaReleaseCommit": "050e7ef3bffff5eff7e48b93984db171aa46c564",
|
|
311
|
+
"gitHead": "050e7ef3bffff5eff7e48b93984db171aa46c564"
|
|
312
312
|
}
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
# on PreToolUse. jq parses the JSON envelope (per project rule: never grep/sed
|
|
27
27
|
# JSON); the command string itself is matched with grep (it is a plain string,
|
|
28
28
|
# not JSON). Malformed/empty stdin → allow (fail open, never crash the tool).
|
|
29
|
+
#
|
|
30
|
+
# Capability names below are the canonical guard's, kept identical on every agent.
|
|
31
|
+
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization
|
|
29
32
|
set -uo pipefail
|
|
30
33
|
|
|
31
34
|
allow() {
|
|
@@ -100,10 +103,42 @@ except ValueError:
|
|
|
100
103
|
|
|
101
104
|
normalized_tokens = [token.strip("();|&") for token in tokens]
|
|
102
105
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
106
|
+
# The only relocations that keep a repo's own hooks in play. `.husky` is the
|
|
107
|
+
# husky convention this fleet runs; `.githooks` is the common hand-rolled one.
|
|
108
|
+
# Anything else — including "" and /dev/null, which are simply the two most
|
|
109
|
+
# obvious members of the blocked set rather than special cases — is refused.
|
|
110
|
+
PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_permitted_hooks_path(value):
|
|
114
|
+
"""Whether a core.hooksPath value relocates hooks rather than disabling them.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
value: The raw core.hooksPath value as it appeared on the command line.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if the path is an established in-repo hooks directory.
|
|
121
|
+
"""
|
|
122
|
+
cleaned = value.strip().strip("'\"")
|
|
123
|
+
if cleaned.startswith("./"):
|
|
124
|
+
cleaned = cleaned[2:]
|
|
125
|
+
return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Git resolves any UNAMBIGUOUS abbreviation of a long option, so `--no-verify`
|
|
129
|
+
# is only the longest of the spellings that disable verification: `git commit
|
|
130
|
+
# --no-veri` skips hooks exactly as completely. An equality check therefore
|
|
131
|
+
# enforced the guard against the one spelling nobody in a hurry types.
|
|
132
|
+
#
|
|
133
|
+
# Matched as "a prefix of --no-verify" rather than by listing abbreviations,
|
|
134
|
+
# because the set of accepted abbreviations is a property of git's parser and
|
|
135
|
+
# changes with the surrounding options. `--no-verbose` is NOT caught, and must
|
|
136
|
+
# not be: it diverges from `--no-verify` at the character after `--no-ver`, so
|
|
137
|
+
# it fails the prefix test.
|
|
138
|
+
#
|
|
139
|
+
# The floor is `--no-v`. Shorter is refused anyway — bare `--no-` is not a flag
|
|
140
|
+
# — and blocking an abbreviation git would reject as ambiguous costs nothing,
|
|
141
|
+
# while missing one git accepts costs the whole guard.
|
|
107
142
|
NO_VERIFY = "--no-verify"
|
|
108
143
|
NO_VERIFY_MIN_PREFIX = len("--no-v")
|
|
109
144
|
|
|
@@ -123,89 +158,113 @@ def disables_verification(token):
|
|
|
123
158
|
and NO_VERIFY.startswith(token)
|
|
124
159
|
)
|
|
125
160
|
|
|
126
|
-
# The only relocations that keep a repo's own hooks in play. Anything else —
|
|
127
|
-
# including "" and /dev/null, which are simply the two most obvious members of
|
|
128
|
-
# the blocked set rather than special cases — is refused.
|
|
129
|
-
PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def is_permitted_hooks_path(value):
|
|
133
|
-
"""Whether a core.hooksPath value relocates hooks rather than disabling them.
|
|
134
|
-
|
|
135
|
-
Args:
|
|
136
|
-
value: The raw core.hooksPath value as it appeared on the command line.
|
|
137
|
-
|
|
138
|
-
Returns:
|
|
139
|
-
True if the path is an established in-repo hooks directory.
|
|
140
|
-
"""
|
|
141
|
-
cleaned = value.strip().strip("'\"")
|
|
142
|
-
if cleaned.startswith("./"):
|
|
143
|
-
cleaned = cleaned[2:]
|
|
144
|
-
return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
|
|
145
|
-
|
|
146
161
|
|
|
147
162
|
# `git commit -n` is the short spelling of --no-verify and skips pre-commit and
|
|
148
|
-
# commit-msg identically
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
# `git commit` reads it as a hook
|
|
152
|
-
#
|
|
153
|
-
# rationale for why a tokenizer can make this distinction and a grep could not.
|
|
163
|
+
# commit-msg identically. Matching it needs one thing the `--no-verify` scan
|
|
164
|
+
# above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
|
|
165
|
+
# merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
|
|
166
|
+
# would refuse ordinary commands all day. Only `git commit` reads it as a hook
|
|
167
|
+
# bypass, so only that invocation's argv is scanned.
|
|
154
168
|
#
|
|
155
|
-
#
|
|
156
|
-
# `shlex.split`
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
169
|
+
# The tokenizer below is a SECOND pass, deliberately not shared with the one the
|
|
170
|
+
# checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
|
|
171
|
+
# which those checks paper over by stripping the punctuation off each token —
|
|
172
|
+
# fine when the question is "does this token look like a bypass anywhere in the
|
|
173
|
+
# line", fatal when the question is "where does this command end", because
|
|
174
|
+
# `git commit -m x && grep -n foo` would then read as one long git invocation
|
|
175
|
+
# and refuse the grep. `punctuation_chars=True` emits the operators as their own
|
|
176
|
+
# tokens, so the invocation's boundary is a token the scan can stop at.
|
|
160
177
|
COMMAND_SEPARATORS = {
|
|
161
178
|
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
162
179
|
}
|
|
163
180
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
#
|
|
181
|
+
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
182
|
+
MAX_SHELL_NESTING = 8
|
|
183
|
+
|
|
184
|
+
# GNU env's long options, listed in FULL so an abbreviation can be resolved
|
|
185
|
+
# against them. getopt_long accepts any prefix that names exactly one option,
|
|
186
|
+
# and no other env option begins with `s` — so `env --s`, `env --sp`, and
|
|
187
|
+
# `env --split` are `env --split-string` just as completely as the full
|
|
188
|
+
# spelling is. Matching only the full spelling is bypassed by deleting
|
|
189
|
+
# characters, which is why the set below exists instead of a literal list of
|
|
190
|
+
# the two spellings the guard used to know.
|
|
168
191
|
ENV_LONG_OPTIONS = {
|
|
169
192
|
"--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
|
|
170
193
|
"--help", "--ignore-environment", "--ignore-signal",
|
|
171
194
|
"--list-signal-handling", "--null", "--split-string", "--unset",
|
|
172
195
|
"--version",
|
|
173
196
|
}
|
|
174
|
-
# Of those, the ones whose value is a SEPARATE token
|
|
175
|
-
#
|
|
197
|
+
# Of those, the ones whose value is a SEPARATE token, so the operand is never
|
|
198
|
+
# mistaken for the command (`env -u bash -c ...` consumes `bash` as the
|
|
199
|
+
# variable name). The signal options take an OPTIONAL value, which GNU reads
|
|
200
|
+
# only when it is attached with `=`, so they never consume a following token.
|
|
176
201
|
ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
|
|
202
|
+
|
|
203
|
+
# env's single-letter options, by whether the rest of a cluster is more
|
|
204
|
+
# options or the option's value.
|
|
177
205
|
ENV_SHORT_NO_VALUE = frozenset("i0v")
|
|
178
206
|
ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
|
|
179
207
|
|
|
180
|
-
# Builtins and wrappers that run whatever FOLLOWS them without changing what
|
|
181
|
-
# is
|
|
208
|
+
# Builtins and wrappers that run whatever FOLLOWS them without changing what
|
|
209
|
+
# it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
|
|
210
|
+
# and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
|
|
211
|
+
# the command is how a payload hides from a guard that inspects only the first
|
|
212
|
+
# word of an invocation.
|
|
182
213
|
COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
|
|
214
|
+
# The only option of those that consumes a separate following token
|
|
215
|
+
# (`exec -a NAME env -S ...`).
|
|
183
216
|
COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
|
|
184
217
|
|
|
185
|
-
#
|
|
186
|
-
#
|
|
218
|
+
# Shell options whose following token is an operand, not another option. The
|
|
219
|
+
# generic recursive scanner must step over these before looking for `-c`.
|
|
220
|
+
SHELL_OPTIONS_SEPARATE_VALUE = {
|
|
221
|
+
"-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
|
|
222
|
+
}
|
|
223
|
+
SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
|
|
224
|
+
|
|
225
|
+
# Boolean long options of the recognized shells. Listed only for PRECISION:
|
|
226
|
+
# a long option is never the command-string flag either way, but naming the
|
|
227
|
+
# ordinary ones keeps `bash --norc script.sh` from being treated as a possible
|
|
228
|
+
# value-taking option whose operand must be scanned past. zsh and ksh accept a
|
|
229
|
+
# long option per shell setting, so this set is deliberately not exhaustive —
|
|
230
|
+
# the scanner stays correct on the ones it does not name.
|
|
231
|
+
SHELL_LONG_OPTIONS_NO_VALUE = {
|
|
232
|
+
"--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
|
|
233
|
+
"--globalrcs", "--help", "--interactive", "--login", "--monitor",
|
|
234
|
+
"--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
|
|
235
|
+
"--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
|
|
236
|
+
"--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
|
|
237
|
+
"--xtrace",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# git's own options that take a SEPARATE value token, which therefore must be
|
|
241
|
+
# skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
|
|
242
|
+
# `git -C /repo commit` both reach `commit`.
|
|
187
243
|
GIT_GLOBAL_SEPARATE_VALUE = {
|
|
188
244
|
"-c", "-C", "--config-env", "--git-dir", "--work-tree",
|
|
189
245
|
"--namespace", "--exec-path", "--super-prefix",
|
|
190
246
|
}
|
|
191
247
|
|
|
192
|
-
# `git commit` long options whose value can be a separate token
|
|
193
|
-
# VALUE is never
|
|
194
|
-
# Only genuinely value-taking options belong here: a
|
|
195
|
-
# would swallow the
|
|
248
|
+
# `git commit` long options whose value can be a separate token. Listed so the
|
|
249
|
+
# VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
|
|
250
|
+
# must stay allowed. Only genuinely value-taking options belong here: adding a
|
|
251
|
+
# boolean one by mistake would swallow the token after it, and `git commit
|
|
252
|
+
# --amend -n` would go unnoticed.
|
|
196
253
|
COMMIT_LONG_SEPARATE_VALUE = {
|
|
197
254
|
"--message", "--file", "--author", "--date", "--template", "--cleanup",
|
|
198
255
|
"--reuse-message", "--reedit-message", "--fixup", "--squash",
|
|
199
256
|
"--pathspec-from-file", "--trailer",
|
|
200
257
|
}
|
|
201
258
|
|
|
202
|
-
# Short
|
|
203
|
-
#
|
|
204
|
-
# the message "n" and not a
|
|
259
|
+
# Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
|
|
260
|
+
# In a cluster the value is whatever follows them in the same token, or the next
|
|
261
|
+
# token when nothing does — which is why `-mn` is the message "n" and not a
|
|
262
|
+
# bypass, and `-nm msg` IS one.
|
|
205
263
|
COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
|
|
206
264
|
|
|
207
|
-
# Short options taking an OPTIONAL value, which git
|
|
208
|
-
# `-uno` is --untracked-files=no,
|
|
265
|
+
# Short options taking an OPTIONAL value, which git only ever reads attached:
|
|
266
|
+
# `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
|
|
267
|
+
# ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
|
|
209
268
|
COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
|
|
210
269
|
|
|
211
270
|
|
|
@@ -245,8 +304,8 @@ def shell_tokens(text):
|
|
|
245
304
|
line_boundaries_as_separators(text), posix=True, punctuation_chars=True
|
|
246
305
|
)
|
|
247
306
|
lexer.whitespace_split = True
|
|
248
|
-
# shlex treats `#` as a comment introducer and would
|
|
249
|
-
# `shlex.split` disables it, and so must this.
|
|
307
|
+
# shlex treats `#` as a comment introducer by default and would silently
|
|
308
|
+
# truncate the rest of the line; `shlex.split` disables it, and so must this.
|
|
250
309
|
lexer.commenters = ""
|
|
251
310
|
return list(lexer)
|
|
252
311
|
|
|
@@ -258,13 +317,17 @@ def cluster_skips_verification(cluster):
|
|
|
258
317
|
cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
|
|
259
318
|
|
|
260
319
|
Returns:
|
|
261
|
-
A pair (bypasses, consumes_next_token).
|
|
320
|
+
A pair (bypasses, consumes_next_token). `bypasses` is True when a real
|
|
321
|
+
`-n` option is present; `consumes_next_token` is True when the cluster
|
|
322
|
+
ends in a value-taking option whose value is the following token.
|
|
262
323
|
"""
|
|
263
324
|
body = cluster[1:]
|
|
264
325
|
for offset, letter in enumerate(body):
|
|
265
326
|
if letter == "n":
|
|
266
327
|
return (True, False)
|
|
267
328
|
if letter in COMMIT_SHORT_REQUIRED_VALUE:
|
|
329
|
+
# Everything after this letter is the value. It is a separate token
|
|
330
|
+
# only when nothing is attached.
|
|
268
331
|
return (False, offset == len(body) - 1)
|
|
269
332
|
if letter in COMMIT_SHORT_OPTIONAL_VALUE:
|
|
270
333
|
return (False, False)
|
|
@@ -284,12 +347,15 @@ def commit_bypasses_verification(argv):
|
|
|
284
347
|
while index < len(argv):
|
|
285
348
|
token = argv[index]
|
|
286
349
|
index += 1
|
|
287
|
-
# `--` ends the options;
|
|
350
|
+
# `--` ends the options; everything after it is a pathspec, and a file
|
|
351
|
+
# legitimately named `-n` is not a bypass.
|
|
288
352
|
if token in COMMAND_SEPARATORS or token == "--":
|
|
289
353
|
return False
|
|
290
354
|
if token.startswith("--"):
|
|
291
355
|
if token in COMMIT_LONG_SEPARATE_VALUE:
|
|
292
356
|
index += 1
|
|
357
|
+
elif disables_verification(token):
|
|
358
|
+
return True
|
|
293
359
|
continue
|
|
294
360
|
# A bare `-` is git's stdin placeholder, not an option cluster.
|
|
295
361
|
if not token.startswith("-") or token == "-":
|
|
@@ -324,10 +390,14 @@ def subcommand_after_git(tokens, start):
|
|
|
324
390
|
|
|
325
391
|
|
|
326
392
|
def resolve_env_long_option(name):
|
|
327
|
-
"""Resolve one env long option, abbreviations included.
|
|
393
|
+
"""Resolve one GNU env long option, abbreviations included.
|
|
328
394
|
|
|
329
|
-
|
|
330
|
-
|
|
395
|
+
Args:
|
|
396
|
+
name: A token beginning with `--`, with any `=value` already removed.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
The full option spelling; "" when the abbreviation names more than one
|
|
400
|
+
option, which real env refuses; None when nothing matches at all.
|
|
331
401
|
"""
|
|
332
402
|
if not name.startswith("--") or name == "--":
|
|
333
403
|
return None
|
|
@@ -340,17 +410,24 @@ def resolve_env_long_option(name):
|
|
|
340
410
|
|
|
341
411
|
|
|
342
412
|
def env_option_kind(token):
|
|
343
|
-
"""Classify one token in an `env` option position.
|
|
413
|
+
"""Classify one token sitting in an `env` option position.
|
|
344
414
|
|
|
345
|
-
Returns
|
|
346
|
-
|
|
347
|
-
|
|
415
|
+
Returns:
|
|
416
|
+
"split-string" when env will reparse an argv value as shell words,
|
|
417
|
+
"separate-value" when the NEXT token is this option's operand,
|
|
418
|
+
"no-value" when the token stands alone, "ambiguous" when env accepts
|
|
419
|
+
the token but this parser cannot say which option it is, or None when
|
|
420
|
+
the token is not an option.
|
|
348
421
|
"""
|
|
349
422
|
if not token.startswith("-") or token in {"-", "--"}:
|
|
350
423
|
return None
|
|
351
424
|
if token.startswith("--"):
|
|
352
|
-
|
|
425
|
+
name = token.split("=", 1)[0]
|
|
426
|
+
resolved = resolve_env_long_option(name)
|
|
353
427
|
if not resolved:
|
|
428
|
+
# Either unknown to this parser or an abbreviation naming several
|
|
429
|
+
# options. Neither can be reasoned about, so say so and let the
|
|
430
|
+
# caller fail closed rather than read past it.
|
|
354
431
|
return "ambiguous"
|
|
355
432
|
if resolved == "--split-string":
|
|
356
433
|
return "split-string"
|
|
@@ -363,8 +440,8 @@ def env_option_kind(token):
|
|
|
363
440
|
if letter == "S":
|
|
364
441
|
return "split-string"
|
|
365
442
|
if letter in ENV_SHORT_TAKES_VALUE:
|
|
366
|
-
# The rest of
|
|
367
|
-
# otherwise the value is the following token.
|
|
443
|
+
# The rest of this cluster is the option's value when there is
|
|
444
|
+
# one; otherwise the value is the following token.
|
|
368
445
|
return "no-value" if position <= len(token) - 1 else "separate-value"
|
|
369
446
|
if letter not in ENV_SHORT_NO_VALUE:
|
|
370
447
|
return "ambiguous"
|
|
@@ -372,17 +449,24 @@ def env_option_kind(token):
|
|
|
372
449
|
|
|
373
450
|
|
|
374
451
|
def strip_command_prefix(prefix):
|
|
375
|
-
"""Drop tokens that precede a command without changing what it is.
|
|
452
|
+
"""Drop the tokens that precede a command without changing what it is.
|
|
453
|
+
|
|
454
|
+
Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
|
|
455
|
+
`env` wrappers all leave the following word in command position. Reading
|
|
456
|
+
any of them as the command itself is what let `command env -S ...` and
|
|
457
|
+
`env -- env -S ...` slip past the split-string check: the prefix failed
|
|
458
|
+
the "assignments only" test, so the wrapped `env` was never classified.
|
|
376
459
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
...` past the split-string check: the prefix failed the assignments-only
|
|
381
|
-
test, so the wrapped `env` was never classified.
|
|
460
|
+
Args:
|
|
461
|
+
prefix: The tokens between the last command separator and the
|
|
462
|
+
candidate program.
|
|
382
463
|
|
|
383
|
-
Returns
|
|
384
|
-
|
|
385
|
-
|
|
464
|
+
Returns:
|
|
465
|
+
A pair (status, remainder). "ok" carries the tokens still standing
|
|
466
|
+
between the separator and the candidate — empty means the candidate is
|
|
467
|
+
in command position. "operand" means the candidate is a wrapper
|
|
468
|
+
option's value rather than a command. "ambiguous" means a wrapper
|
|
469
|
+
option could not be classified and the caller must fail closed.
|
|
386
470
|
"""
|
|
387
471
|
cursor = 0
|
|
388
472
|
while cursor < len(prefix):
|
|
@@ -417,6 +501,8 @@ def strip_command_prefix(prefix):
|
|
|
417
501
|
if kind == "ambiguous":
|
|
418
502
|
return ("ambiguous", [])
|
|
419
503
|
if kind == "split-string":
|
|
504
|
+
# Everything after `-S` is env's own opaque payload, so the
|
|
505
|
+
# candidate is not a command at all.
|
|
420
506
|
return ("operand", [])
|
|
421
507
|
if kind == "separate-value":
|
|
422
508
|
if cursor + 1 >= len(prefix):
|
|
@@ -429,8 +515,33 @@ def strip_command_prefix(prefix):
|
|
|
429
515
|
return ("ok", prefix[cursor:])
|
|
430
516
|
|
|
431
517
|
|
|
518
|
+
def shell_starts_command(tokens, index):
|
|
519
|
+
"""Whether a recognized shell token occupies command position."""
|
|
520
|
+
start = index - 1
|
|
521
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
522
|
+
start -= 1
|
|
523
|
+
status, remainder = strip_command_prefix(tokens[start + 1:index])
|
|
524
|
+
if status == "ambiguous":
|
|
525
|
+
# A prefix option this parser cannot classify may still be launching
|
|
526
|
+
# the shell. Fail closed rather than trusting the wrapper.
|
|
527
|
+
return True
|
|
528
|
+
return status == "ok" and not remainder
|
|
529
|
+
|
|
530
|
+
|
|
432
531
|
def env_uses_split_string(tokens, index):
|
|
433
|
-
"""Whether a command-position env invocation
|
|
532
|
+
"""Whether a command-position env invocation uses split-string parsing.
|
|
533
|
+
|
|
534
|
+
`env -S` reparses one opaque argv value as shell-like words. Inspecting the
|
|
535
|
+
outer command cannot prove what executable or nested shell that second
|
|
536
|
+
parse will produce, so the verification guard refuses the ambiguous form.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
tokens: The full operator-aware token list.
|
|
540
|
+
index: Index of the candidate env executable.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
True when env will reparse a split-string payload.
|
|
544
|
+
"""
|
|
434
545
|
start = index - 1
|
|
435
546
|
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
436
547
|
start -= 1
|
|
@@ -444,9 +555,11 @@ def env_uses_split_string(tokens, index):
|
|
|
444
555
|
if token in COMMAND_SEPARATORS or token == "--":
|
|
445
556
|
return False
|
|
446
557
|
kind = env_option_kind(token)
|
|
447
|
-
if kind
|
|
448
|
-
|
|
449
|
-
|
|
558
|
+
if kind == "split-string":
|
|
559
|
+
return True
|
|
560
|
+
if kind == "ambiguous":
|
|
561
|
+
# An option env accepts but this parser cannot name makes the rest
|
|
562
|
+
# of the invocation unreadable; refuse instead of reading past it.
|
|
450
563
|
return True
|
|
451
564
|
if kind is None:
|
|
452
565
|
if "=" in token and not token.startswith(("=", "-")):
|
|
@@ -457,11 +570,55 @@ def env_uses_split_string(tokens, index):
|
|
|
457
570
|
return False
|
|
458
571
|
|
|
459
572
|
|
|
460
|
-
def
|
|
573
|
+
def nested_shell_payload(tokens, index):
|
|
574
|
+
"""Return a shell `-c` payload, or True when option parsing is ambiguous."""
|
|
575
|
+
cursor = index + 1
|
|
576
|
+
opaque_option_seen = False
|
|
577
|
+
while cursor < len(tokens):
|
|
578
|
+
option = tokens[cursor]
|
|
579
|
+
if option in COMMAND_SEPARATORS or option == "--":
|
|
580
|
+
return None
|
|
581
|
+
if not option.startswith(("-", "+")):
|
|
582
|
+
# Ordinarily the script operand, which ends option parsing. After
|
|
583
|
+
# a long option this parser cannot name it may instead be that
|
|
584
|
+
# option's value (`zsh --emulate ksh -c ...`), so keep scanning
|
|
585
|
+
# rather than concluding there is no command string.
|
|
586
|
+
if not opaque_option_seen:
|
|
587
|
+
return None
|
|
588
|
+
cursor += 1
|
|
589
|
+
continue
|
|
590
|
+
if option in SHELL_OPTIONS_SEPARATE_VALUE:
|
|
591
|
+
if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
|
|
592
|
+
return True
|
|
593
|
+
cursor += 2
|
|
594
|
+
continue
|
|
595
|
+
if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
|
|
596
|
+
cursor += 1
|
|
597
|
+
continue
|
|
598
|
+
# A LONG option is never the command-string flag, and testing it for a
|
|
599
|
+
# bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
|
|
600
|
+
# so the real `-c` that followed was consumed as their payload and the
|
|
601
|
+
# nested `git commit --no-verify` was never classified at all. The
|
|
602
|
+
# command string lives only in a single-dash cluster (`-c`, `-lc`).
|
|
603
|
+
if option.startswith("--"):
|
|
604
|
+
if option not in SHELL_LONG_OPTIONS_NO_VALUE:
|
|
605
|
+
opaque_option_seen = True
|
|
606
|
+
cursor += 1
|
|
607
|
+
continue
|
|
608
|
+
if "c" in option[1:]:
|
|
609
|
+
if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
|
|
610
|
+
return True
|
|
611
|
+
return tokens[cursor + 1]
|
|
612
|
+
cursor += 1
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def git_commit_skips_verification(text, depth=0):
|
|
461
617
|
"""Whether the command runs `git commit` with the short `-n` bypass.
|
|
462
618
|
|
|
463
619
|
Args:
|
|
464
620
|
text: The command line, heredoc payloads already stripped.
|
|
621
|
+
depth: Number of recognized shell payloads already traversed.
|
|
465
622
|
|
|
466
623
|
Returns:
|
|
467
624
|
True if any `git commit` invocation on the line skips verification.
|
|
@@ -483,6 +640,21 @@ def git_commit_skips_verification(text):
|
|
|
483
640
|
found = subcommand_after_git(scoped_tokens, index + 1)
|
|
484
641
|
if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
|
|
485
642
|
return True
|
|
643
|
+
if depth >= MAX_SHELL_NESTING:
|
|
644
|
+
return True
|
|
645
|
+
for index, token in enumerate(scoped_tokens):
|
|
646
|
+
program = token.rsplit("/", 1)[-1]
|
|
647
|
+
if program not in SHELL_PROGRAMS or not shell_starts_command(
|
|
648
|
+
scoped_tokens, index
|
|
649
|
+
):
|
|
650
|
+
continue
|
|
651
|
+
payload = nested_shell_payload(scoped_tokens, index)
|
|
652
|
+
if payload is True:
|
|
653
|
+
return True
|
|
654
|
+
if isinstance(payload, str) and git_commit_skips_verification(
|
|
655
|
+
payload, depth + 1
|
|
656
|
+
):
|
|
657
|
+
return True
|
|
486
658
|
return False
|
|
487
659
|
|
|
488
660
|
|
|
@@ -494,11 +666,19 @@ for i, token in enumerate(normalized_tokens):
|
|
|
494
666
|
sys.exit(1)
|
|
495
667
|
if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
|
|
496
668
|
sys.exit(1)
|
|
497
|
-
# Allowlist the destinations, do not denylist the disabling ones
|
|
498
|
-
#
|
|
499
|
-
#
|
|
500
|
-
#
|
|
501
|
-
#
|
|
669
|
+
# Allowlist the destinations, do not denylist the disabling ones.
|
|
670
|
+
#
|
|
671
|
+
# This used to block only "" and /dev/null. But hooks are disabled just as
|
|
672
|
+
# completely by pointing hooksPath at any directory that happens to contain
|
|
673
|
+
# none — `-c core.hooksPath=/tmp/empty` bypassed every hook while reading as
|
|
674
|
+
# an ordinary path — so a denylist of "obviously disabling" values can
|
|
675
|
+
# always be stepped around by naming a third thing. The set of paths that
|
|
676
|
+
# DISABLE hooks is unbounded; the set that legitimately relocates them is
|
|
677
|
+
# tiny and known, so the allowlist is the only side that can be enumerated.
|
|
678
|
+
#
|
|
679
|
+
# Matched case-insensitively because git config variable names are:
|
|
680
|
+
# `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to git,
|
|
681
|
+
# so a case-sensitive check is bypassed by holding down shift.
|
|
502
682
|
lowered = token.lower()
|
|
503
683
|
if lowered.startswith("core.hookspath="):
|
|
504
684
|
if not is_permitted_hooks_path(token.split("=", 1)[1]):
|
|
@@ -506,30 +686,55 @@ for i, token in enumerate(normalized_tokens):
|
|
|
506
686
|
if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
|
|
507
687
|
if not is_permitted_hooks_path(normalized_tokens[i + 1]):
|
|
508
688
|
sys.exit(1)
|
|
509
|
-
#
|
|
510
|
-
#
|
|
689
|
+
# `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
|
|
690
|
+
# the value out of the named environment variable. The path therefore is
|
|
691
|
+
# not in the command at all, so there is nothing to allowlist against —
|
|
692
|
+
# SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
|
|
693
|
+
# routed through --config-env is refused outright.
|
|
511
694
|
if lowered.startswith("--config-env="):
|
|
512
695
|
spec = token.split("=", 1)[1]
|
|
513
696
|
if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
|
|
514
697
|
sys.exit(1)
|
|
515
|
-
# git
|
|
516
|
-
# only the `=` spelling
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
698
|
+
# git accepts `--config-env <name>=<envvar>` as TWO tokens as well as one,
|
|
699
|
+
# and guarding only the `=` spelling was worse than missing the separate
|
|
700
|
+
# form outright: the trailing `core.hooksPath=.husky` then fell through to
|
|
701
|
+
# the allowlist above, which reads `.husky` as a PATH and permits it. But
|
|
702
|
+
# here it is an ENVIRONMENT VARIABLE NAME, and `env '.husky=/dev/null' git
|
|
703
|
+
# --config-env core.hooksPath=.husky` really does resolve hooksPath to
|
|
704
|
+
# /dev/null. The allowlist was being used as the bypass.
|
|
705
|
+
#
|
|
706
|
+
# Checked at the `--config-env` token, which the loop reaches first, so the
|
|
707
|
+
# refusal happens before the value token can be mistaken for a path.
|
|
520
708
|
if lowered == "--config-env" and i + 1 < len(normalized_tokens):
|
|
521
709
|
spec = normalized_tokens[i + 1]
|
|
522
710
|
if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
|
|
523
711
|
sys.exit(1)
|
|
524
712
|
# `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
|
|
525
|
-
# GIT_CONFIG_VALUE_0=/dev/null git
|
|
526
|
-
#
|
|
527
|
-
#
|
|
713
|
+
# GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
|
|
714
|
+
# same way `-c core.hooksPath=...` does — env-var-style assignments ahead of
|
|
715
|
+
# the invocation instead of a flag — so it disables every hook just as
|
|
716
|
+
# completely while matching none of the token shapes above. Upstream missed
|
|
717
|
+
# this until a downstream fork hardened its own copy against it, which is
|
|
718
|
+
# the one direction a guard must never be caught in.
|
|
719
|
+
#
|
|
720
|
+
# The index is matched as `\d+` rather than pinned to 0: git accepts any
|
|
721
|
+
# index below GIT_CONFIG_COUNT, so a single-index check is evaded by typing
|
|
722
|
+
# a 1. Refused outright, like --config-env=, because the path lives in a
|
|
723
|
+
# separate GIT_CONFIG_VALUE_<n> token that can be exported earlier,
|
|
724
|
+
# reordered, or left out entirely — there is nothing here to allowlist
|
|
725
|
+
# against.
|
|
528
726
|
key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
|
|
529
727
|
if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
|
|
530
728
|
sys.exit(1)
|
|
729
|
+
# `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
|
|
730
|
+
# Parsing the value as Git's shell-quoted parameter list distinguishes the
|
|
731
|
+
# key from an unrelated value that merely contains the same text.
|
|
531
732
|
if re.match(r"git_config_parameters\+?=", lowered):
|
|
532
733
|
parameters = token.split("=", 1)[1]
|
|
734
|
+
# The guard sees the command before the shell expands assignments. A
|
|
735
|
+
# variable or command substitution can therefore hide hooksPath from
|
|
736
|
+
# this parser and reveal it only to Git. Refuse unresolved values; the
|
|
737
|
+
# guard cannot safely prove what configuration they will become.
|
|
533
738
|
if "$" in parameters or "`" in parameters:
|
|
534
739
|
sys.exit(1)
|
|
535
740
|
try:
|