@codyswann/lisa 4.24.1 → 4.25.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 (89) hide show
  1. package/all/copy-overwrite/scripts/check-orphaned-branches.mjs +218 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +218 -82
  3. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +85 -12
  4. package/all/copy-overwrite/scripts/lisa-work-item.mjs +74 -15
  5. package/dist/cli/ui-config-write-persistence.d.ts +12 -2
  6. package/dist/cli/ui-config-write-persistence.d.ts.map +1 -1
  7. package/dist/cli/ui-config-write-persistence.js +7 -3
  8. package/dist/cli/ui-config-write-persistence.js.map +1 -1
  9. package/dist/cli/ui-config-write.d.ts +1 -1
  10. package/dist/cli/ui-config-write.d.ts.map +1 -1
  11. package/dist/cli/ui-config-write.js +62 -6
  12. package/dist/cli/ui-config-write.js.map +1 -1
  13. package/dist/codex/scripts/block-no-verify.sh +134 -37
  14. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  15. package/dist/core/lisa-owned-hash-ledger.js +16 -0
  16. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  17. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  18. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  19. package/dist/core/upstream-evidence-manifest.js +14 -10
  20. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  21. package/dist/opencode/plugin-templates/block-no-verify.sh +218 -82
  22. package/package.json +3 -3
  23. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +12 -0
  26. package/plugins/lisa/hooks/block-no-verify.agy.sh +134 -37
  27. package/plugins/lisa/hooks/block-no-verify.sh +218 -82
  28. package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +12 -0
  29. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +134 -37
  30. package/plugins/lisa-agy/plugin.json +1 -1
  31. package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +12 -0
  32. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  35. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-copilot/hooks/block-no-verify.sh +218 -82
  39. package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +12 -0
  40. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cursor/hooks/block-no-verify.sh +218 -82
  42. package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +12 -0
  43. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-agy/plugin.json +1 -1
  46. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  66. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails-agy/plugin.json +1 -1
  71. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  76. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  81. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/src/base/hooks/block-no-verify.agy.sh +134 -37
  84. package/plugins/src/base/hooks/block-no-verify.sh +218 -82
  85. package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +12 -0
  86. package/scripts/check-security-floors.mjs +290 -31
  87. package/scripts/two-channel-couplings.json +1 -1
  88. package/typescript/copy-overwrite/.prettierignore +1 -0
  89. package/ui/README.md +30 -2
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+ /**
5
+ * Report remote branches carrying work that was pushed and never submitted
6
+ * (issue #3446).
7
+ *
8
+ * ## The state this exists for
9
+ *
10
+ * `lisa-git-submit-pr` lists **Push** and **PR Management** as separate steps
11
+ * with nothing binding them: no atomicity, no post-condition, no terminal check
12
+ * that a pull request exists when the flow ends. An agent that stops between
13
+ * them — context exhaustion, a turn limit, an interruption, a refused call —
14
+ * leaves a branch on the remote carrying finished work that is in no review, no
15
+ * CI run, and no report.
16
+ *
17
+ * The skill's own `Never` list contained the rule for exactly this pair of
18
+ * steps, pointing the other way: *"skip pushing before PR creation"* forbids a
19
+ * PR without a push. Push without a PR was unprohibited and undetected.
20
+ *
21
+ * **The last signal such an agent receives is green.** The pre-push hook prints
22
+ * `no pull request exists yet, so gates 4 and 5 could not be checked here — CI
23
+ * will verify both`, which is accurate and reads as an informational pass.
24
+ * Downstream the silence holds: the work item keeps its in-progress role, so
25
+ * intake will not re-dispatch it, and nothing counts a branch without a pull
26
+ * request. The state is unreachable by every other check.
27
+ *
28
+ * Measured when this was written: 15 such branches in one repository, roughly
29
+ * 12,900 insertions, every one dated within a single day. Fifteen simultaneous
30
+ * stops at the same step is not fifteen coincidences.
31
+ *
32
+ * ## What it reports, and what it deliberately does not
33
+ *
34
+ * Reported: a remote branch **ahead** of the default branch, with **no** pull
35
+ * request of any state.
36
+ *
37
+ * A branch level with the default branch has nothing to submit. A branch whose
38
+ * pull request already MERGED is an un-deleted leftover, not lost work — and
39
+ * keying on "no OPEN pull request" instead would report every such branch in
40
+ * the repository. Both exclusions exist because a check that reports everything
41
+ * is one nobody reads, which is the same outcome as no check at all.
42
+ *
43
+ * Determinism: Node built-ins only, no `Date`, no `Math.random`. Branch and
44
+ * pull-request facts come from `git` and `gh`; with neither available the
45
+ * script reports that it could not look rather than that it found nothing.
46
+ * @module check-orphaned-branches
47
+ */
48
+
49
+ import { spawnSync } from "node:child_process";
50
+
51
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
52
+
53
+ /** Branch name prefixes that are never work-in-flight. */
54
+ const IGNORED_PREFIXES = Object.freeze(["backup/", "gh-readonly-queue/"]);
55
+
56
+ /** Exit codes. `FOUND` is advisory by default; see `--fail-on-found`. */
57
+ const EXIT = Object.freeze({ OK: 0, FOUND: 1, UNAVAILABLE: 70 });
58
+
59
+ /**
60
+ * Run a command and return its trimmed stdout, or undefined on any failure.
61
+ *
62
+ * Undefined means "could not look", never "looked and found nothing" — the two
63
+ * are reported differently, because a check that turns a broken command into a
64
+ * clean result is the vacuous-success failure this file's family exists to
65
+ * refuse.
66
+ * @param {string} command Executable name.
67
+ * @param {string[]} args Arguments.
68
+ * @returns {string | undefined} Trimmed stdout, or undefined.
69
+ */
70
+ function run(command, args) {
71
+ const result = spawnSync(command, args, {
72
+ encoding: "utf8",
73
+ maxBuffer: 8 * 1024 * 1024,
74
+ });
75
+ if (result.error || result.status !== 0) return undefined;
76
+ return result.stdout.trim();
77
+ }
78
+
79
+ /**
80
+ * The repository's default branch, as the remote reports it.
81
+ * @param {string} remote Remote name.
82
+ * @returns {string | undefined} Short branch name, or undefined.
83
+ */
84
+ export function defaultBranch(remote = "origin") {
85
+ const symref = run("git", [
86
+ "symbolic-ref",
87
+ "--quiet",
88
+ `refs/remotes/${remote}/HEAD`,
89
+ ]);
90
+ return symref?.startsWith(`refs/remotes/${remote}/`)
91
+ ? symref.slice(`refs/remotes/${remote}/`.length)
92
+ : undefined;
93
+ }
94
+
95
+ /**
96
+ * Every branch name that has a pull request, in any state.
97
+ *
98
+ * Any state, not just OPEN. A branch whose pull request merged is a leftover
99
+ * ref, not unsubmitted work, and reporting it would bury the real findings
100
+ * under every branch nobody deleted.
101
+ * @returns {Set<string> | undefined} Branch names, or undefined if `gh` failed.
102
+ */
103
+ export function branchesWithPullRequests() {
104
+ const json = run("gh", [
105
+ "pr",
106
+ "list",
107
+ "--state",
108
+ "all",
109
+ "--limit",
110
+ "1000",
111
+ "--json",
112
+ "headRefName",
113
+ ]);
114
+ if (json === undefined) return undefined;
115
+ try {
116
+ const rows = JSON.parse(json);
117
+ return new Set(
118
+ Array.isArray(rows)
119
+ ? rows
120
+ .map(row => row?.headRefName)
121
+ .filter(name => typeof name === "string")
122
+ : []
123
+ );
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Remote branches ahead of the default branch, with their commit counts.
131
+ * @param {string} remote Remote name.
132
+ * @param {string} base Default branch name.
133
+ * @returns {ReadonlyArray<{branch: string, ahead: number}> | undefined} Rows.
134
+ */
135
+ export function branchesAhead(remote = "origin", base = "main") {
136
+ const listed = run("git", [
137
+ "for-each-ref",
138
+ "--format=%(refname:strip=3)",
139
+ `refs/remotes/${remote}`,
140
+ ]);
141
+ if (listed === undefined) return undefined;
142
+ const rows = [];
143
+ for (const branch of listed.split("\n").map(line => line.trim())) {
144
+ if (!branch || branch === base || branch === "HEAD") continue;
145
+ if (IGNORED_PREFIXES.some(prefix => branch.startsWith(prefix))) continue;
146
+ const count = run("git", [
147
+ "rev-list",
148
+ "--count",
149
+ `${remote}/${base}..${remote}/${branch}`,
150
+ ]);
151
+ const ahead = Number.parseInt(count ?? "", 10);
152
+ if (Number.isFinite(ahead) && ahead > 0) rows.push({ ahead, branch });
153
+ }
154
+ return rows;
155
+ }
156
+
157
+ /**
158
+ * Branches carrying commits that no pull request has ever covered.
159
+ * @param {object} inputs Inputs.
160
+ * @param {ReadonlyArray<{branch: string, ahead: number}>} inputs.ahead Branches ahead of base.
161
+ * @param {Set<string>} inputs.submitted Branch names that have a pull request.
162
+ * @returns {ReadonlyArray<{branch: string, ahead: number}>} The orphans.
163
+ */
164
+ export function orphanedBranches({ ahead, submitted }) {
165
+ return ahead.filter(row => !submitted.has(row.branch));
166
+ }
167
+
168
+ /**
169
+ * Report orphaned branches.
170
+ * @param {string[]} argv Arguments.
171
+ * @returns {number} Exit code.
172
+ */
173
+ export function main(argv = process.argv.slice(2)) {
174
+ const remote = "origin";
175
+ const base = defaultBranch(remote);
176
+ if (!base) {
177
+ console.error(
178
+ "check-orphaned-branches: could not resolve the remote default branch, so nothing was checked. This is NOT a clean result."
179
+ );
180
+ return EXIT.UNAVAILABLE;
181
+ }
182
+ const ahead = branchesAhead(remote, base);
183
+ const submitted = branchesWithPullRequests();
184
+ if (ahead === undefined || submitted === undefined) {
185
+ console.error(
186
+ `check-orphaned-branches: could not read ${ahead === undefined ? "branches" : "pull requests"}, so nothing was checked. This is NOT a clean result.`
187
+ );
188
+ return EXIT.UNAVAILABLE;
189
+ }
190
+ const orphans = orphanedBranches({ ahead, submitted });
191
+ if (orphans.length === 0) {
192
+ console.log(
193
+ `check-orphaned-branches: every remote branch ahead of ${base} has a pull request.`
194
+ );
195
+ return EXIT.OK;
196
+ }
197
+ console.log(
198
+ `check-orphaned-branches: ${orphans.length} branch(es) carry commits that were pushed and never submitted.\n`
199
+ );
200
+ for (const { branch, ahead: count } of [...orphans].sort((a, b) =>
201
+ a.branch.localeCompare(b.branch)
202
+ ))
203
+ console.log(
204
+ ` ${branch} — ${count} commit(s) ahead of ${base}, no pull request`
205
+ );
206
+ console.log(
207
+ "\nWork on these branches is in no review, no CI run, and no report, and it drifts from " +
208
+ `${base} until it is submitted or deleted. Open a pull request for each, or delete the branch ` +
209
+ "if the work is abandoned — but decide, rather than leaving it invisible."
210
+ );
211
+ return argv.includes("--fail-on-found") ? EXIT.FOUND : EXIT.OK;
212
+ }
213
+
214
+ if (invokedAsScript(import.meta.url)) {
215
+ // exitCode (not process.exit): when stdout is a pipe, writes are async and
216
+ // process.exit() truncates the report mid-flush.
217
+ process.exitCode = main(process.argv.slice(2));
218
+ }
@@ -15,7 +15,13 @@
15
15
  # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
16
16
  # 6. GIT_CONFIG_PARAMETERS carrying core.hooksPath command-scope config;
17
17
  # 7. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
18
- # into a short-option cluster such as `-nm "msg"`.
18
+ # into a short-option cluster such as `-nm "msg"`;
19
+ # 8. a nested shell carrying any of the above as its `-c` command string,
20
+ # including behind long shell options such as `bash --norc -c`;
21
+ # 9. `env --split-string` in EVERY abbreviation GNU accepts (`--s` upward,
22
+ # with or without an attached `=value`);
23
+ # 10. those forms behind a wrapper that does not change what runs —
24
+ # `command`, `exec`, `builtin`, `nohup`, `setsid`, and nested `env`.
19
25
  #
20
26
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
21
27
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
@@ -26,7 +32,7 @@
26
32
  # Add a name here in the same commit that closes a vector. A hardening that
27
33
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
28
34
  # review time instead of a named capability.
29
- # 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
35
+ # 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
30
36
  #
31
37
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
32
38
  # commit-message prose while still catching quoted real argv values such as
@@ -222,26 +228,61 @@ COMMAND_SEPARATORS = {
222
228
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
223
229
  MAX_SHELL_NESTING = 8
224
230
 
225
- # Prefix options accepted by `env` before the command name. Value-taking
226
- # options are separate because their operand must never be mistaken for the
227
- # command (`env -u bash -c ...` consumes `bash` as the variable name).
228
- ENV_OPTIONS_NO_VALUE = {
229
- "-i", "--ignore-environment", "-0", "--null", "-v", "--debug",
231
+ # GNU env's long options, listed in FULL so an abbreviation can be resolved
232
+ # against them. getopt_long accepts any prefix that names exactly one option,
233
+ # and no other env option begins with `s` — so `env --s`, `env --sp`, and
234
+ # `env --split` are `env --split-string` just as completely as the full
235
+ # spelling is. Matching only the full spelling is bypassed by deleting
236
+ # characters, which is why the set below exists instead of a literal list of
237
+ # the two spellings the guard used to know.
238
+ ENV_LONG_OPTIONS = {
239
+ "--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
240
+ "--help", "--ignore-environment", "--ignore-signal",
241
+ "--list-signal-handling", "--null", "--split-string", "--unset",
242
+ "--version",
230
243
  }
231
- ENV_OPTIONS_SEPARATE_VALUE = {
232
- "-u", "--unset", "-C", "--chdir", "-S", "--split-string",
233
- "-a", "--argv0",
234
- }
235
- ENV_OPTIONS_INLINE_VALUE = (
236
- "--unset=", "--chdir=", "--split-string=", "--argv0=",
237
- )
244
+ # Of those, the ones whose value is a SEPARATE token, so the operand is never
245
+ # mistaken for the command (`env -u bash -c ...` consumes `bash` as the
246
+ # variable name). The signal options take an OPTIONAL value, which GNU reads
247
+ # only when it is attached with `=`, so they never consume a following token.
248
+ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
249
+
250
+ # env's single-letter options, by whether the rest of a cluster is more
251
+ # options or the option's value.
252
+ ENV_SHORT_NO_VALUE = frozenset("i0v")
253
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
254
+
255
+ # Builtins and wrappers that run whatever FOLLOWS them without changing what
256
+ # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
257
+ # and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
258
+ # the command is how a payload hides from a guard that inspects only the first
259
+ # word of an invocation.
260
+ COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
261
+ # The only option of those that consumes a separate following token
262
+ # (`exec -a NAME env -S ...`).
263
+ COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
238
264
 
239
265
  # Shell options whose following token is an operand, not another option. The
240
266
  # generic recursive scanner must step over these before looking for `-c`.
241
267
  SHELL_OPTIONS_SEPARATE_VALUE = {
242
- "-o", "+o", "-O", "+O", "--rcfile", "--init-file",
268
+ "-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
269
+ }
270
+ SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
271
+
272
+ # Boolean long options of the recognized shells. Listed only for PRECISION:
273
+ # a long option is never the command-string flag either way, but naming the
274
+ # ordinary ones keeps `bash --norc script.sh` from being treated as a possible
275
+ # value-taking option whose operand must be scanned past. zsh and ksh accept a
276
+ # long option per shell setting, so this set is deliberately not exhaustive —
277
+ # the scanner stays correct on the ones it does not name.
278
+ SHELL_LONG_OPTIONS_NO_VALUE = {
279
+ "--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
280
+ "--globalrcs", "--help", "--interactive", "--login", "--monitor",
281
+ "--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
282
+ "--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
283
+ "--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
284
+ "--xtrace",
243
285
  }
244
- SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=")
245
286
 
246
287
  # git's own options that take a SEPARATE value token, which therefore must be
247
288
  # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
@@ -395,56 +436,143 @@ def subcommand_after_git(tokens, start):
395
436
  return None
396
437
 
397
438
 
398
- def shell_starts_command(tokens, index):
399
- """Whether a recognized shell token occupies command position."""
400
- start = index - 1
401
- while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
402
- start -= 1
403
- prefix = tokens[start + 1:index]
404
- if not prefix:
405
- return True
406
- if prefix[0].rsplit("/", 1)[-1] != "env":
407
- return all("=" in token and not token.startswith("=") for token in prefix)
439
+ def resolve_env_long_option(name):
440
+ """Resolve one GNU env long option, abbreviations included.
408
441
 
409
- cursor = 1
442
+ Args:
443
+ name: A token beginning with `--`, with any `=value` already removed.
444
+
445
+ Returns:
446
+ The full option spelling; "" when the abbreviation names more than one
447
+ option, which real env refuses; None when nothing matches at all.
448
+ """
449
+ if not name.startswith("--") or name == "--":
450
+ return None
451
+ if name in ENV_LONG_OPTIONS:
452
+ return name
453
+ matches = [option for option in ENV_LONG_OPTIONS if option.startswith(name)]
454
+ if len(matches) == 1:
455
+ return matches[0]
456
+ return "" if matches else None
457
+
458
+
459
+ def env_option_kind(token):
460
+ """Classify one token sitting in an `env` option position.
461
+
462
+ Returns:
463
+ "split-string" when env will reparse an argv value as shell words,
464
+ "separate-value" when the NEXT token is this option's operand,
465
+ "no-value" when the token stands alone, "ambiguous" when env accepts
466
+ the token but this parser cannot say which option it is, or None when
467
+ the token is not an option.
468
+ """
469
+ if not token.startswith("-") or token in {"-", "--"}:
470
+ return None
471
+ if token.startswith("--"):
472
+ name = token.split("=", 1)[0]
473
+ resolved = resolve_env_long_option(name)
474
+ if not resolved:
475
+ # Either unknown to this parser or an abbreviation naming several
476
+ # options. Neither can be reasoned about, so say so and let the
477
+ # caller fail closed rather than read past it.
478
+ return "ambiguous"
479
+ if resolved == "--split-string":
480
+ return "split-string"
481
+ if "=" in token:
482
+ return "no-value"
483
+ return (
484
+ "separate-value" if resolved in ENV_LONG_SEPARATE_VALUE else "no-value"
485
+ )
486
+ for position, letter in enumerate(token[1:], start=2):
487
+ if letter == "S":
488
+ return "split-string"
489
+ if letter in ENV_SHORT_TAKES_VALUE:
490
+ # The rest of this cluster is the option's value when there is
491
+ # one; otherwise the value is the following token.
492
+ return "no-value" if position <= len(token) - 1 else "separate-value"
493
+ if letter not in ENV_SHORT_NO_VALUE:
494
+ return "ambiguous"
495
+ return "no-value"
496
+
497
+
498
+ def strip_command_prefix(prefix):
499
+ """Drop the tokens that precede a command without changing what it is.
500
+
501
+ Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
502
+ `env` wrappers all leave the following word in command position. Reading
503
+ any of them as the command itself is what let `command env -S ...` and
504
+ `env -- env -S ...` slip past the split-string check: the prefix failed
505
+ the "assignments only" test, so the wrapped `env` was never classified.
506
+
507
+ Args:
508
+ prefix: The tokens between the last command separator and the
509
+ candidate program.
510
+
511
+ Returns:
512
+ A pair (status, remainder). "ok" carries the tokens still standing
513
+ between the separator and the candidate — empty means the candidate is
514
+ in command position. "operand" means the candidate is a wrapper
515
+ option's value rather than a command. "ambiguous" means a wrapper
516
+ option could not be classified and the caller must fail closed.
517
+ """
518
+ cursor = 0
410
519
  while cursor < len(prefix):
411
520
  token = prefix[cursor]
412
- if token == "--":
413
- return cursor == len(prefix) - 1
414
- if "=" in token and not token.startswith("=") and not token.startswith("-"):
521
+ if "=" in token and not token.startswith(("=", "-")):
415
522
  cursor += 1
416
523
  continue
417
- if token in ENV_OPTIONS_NO_VALUE or token.startswith(ENV_OPTIONS_INLINE_VALUE):
524
+ program = token.rsplit("/", 1)[-1]
525
+ if program in COMMAND_PREFIX_WRAPPERS:
418
526
  cursor += 1
527
+ while cursor < len(prefix) and prefix[cursor].startswith("-"):
528
+ if prefix[cursor] == "--":
529
+ cursor += 1
530
+ break
531
+ if prefix[cursor] in COMMAND_PREFIX_SEPARATE_VALUE:
532
+ if cursor + 1 >= len(prefix):
533
+ return ("operand", [])
534
+ cursor += 2
535
+ continue
536
+ cursor += 1
419
537
  continue
420
- if token in ENV_OPTIONS_SEPARATE_VALUE:
421
- # If the option has no operand inside the prefix, the shell token
422
- # itself is that operand and therefore is not command position.
423
- if cursor + 1 >= len(prefix):
424
- return False
425
- cursor += 2
538
+ if program == "env":
539
+ cursor += 1
540
+ while cursor < len(prefix):
541
+ option = prefix[cursor]
542
+ if option == "--":
543
+ cursor += 1
544
+ break
545
+ kind = env_option_kind(option)
546
+ if kind is None:
547
+ break
548
+ if kind == "ambiguous":
549
+ return ("ambiguous", [])
550
+ if kind == "split-string":
551
+ # Everything after `-S` is env's own opaque payload, so the
552
+ # candidate is not a command at all.
553
+ return ("operand", [])
554
+ if kind == "separate-value":
555
+ if cursor + 1 >= len(prefix):
556
+ return ("operand", [])
557
+ cursor += 2
558
+ continue
559
+ cursor += 1
426
560
  continue
427
- if token.startswith("-"):
428
- # An unknown env option makes command-position parsing ambiguous.
429
- # Fail closed for a recognized shell rather than trusting it.
430
- return True
431
- return False
432
- return True
561
+ break
562
+ return ("ok", prefix[cursor:])
433
563
 
434
564
 
435
- def env_short_option_uses_split_string(token):
436
- """Whether one GNU env short-option cluster enables split-string."""
437
- if not token.startswith("-") or token.startswith("--") or token == "-":
438
- return False
439
- for letter in token[1:]:
440
- if letter == "S":
441
- return True
442
- if letter in {"u", "C", "a"}:
443
- # The rest of this token is the option's value, not more options.
444
- return False
445
- if letter not in {"i", "0", "v"}:
446
- return False
447
- return False
565
+ def shell_starts_command(tokens, index):
566
+ """Whether a recognized shell token occupies command position."""
567
+ start = index - 1
568
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
569
+ start -= 1
570
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
571
+ if status == "ambiguous":
572
+ # A prefix option this parser cannot classify may still be launching
573
+ # the shell. Fail closed rather than trusting the wrapper.
574
+ return True
575
+ return status == "ok" and not remainder
448
576
 
449
577
 
450
578
  def env_uses_split_string(tokens, index):
@@ -464,8 +592,8 @@ def env_uses_split_string(tokens, index):
464
592
  start = index - 1
465
593
  while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
466
594
  start -= 1
467
- prefix = tokens[start + 1:index]
468
- if not all("=" in token and not token.startswith("=") for token in prefix):
595
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
596
+ if status != "ok" or remainder:
469
597
  return False
470
598
 
471
599
  cursor = index + 1
@@ -473,40 +601,39 @@ def env_uses_split_string(tokens, index):
473
601
  token = tokens[cursor]
474
602
  if token in COMMAND_SEPARATORS or token == "--":
475
603
  return False
476
- if (
477
- token in {"-S", "--split-string"}
478
- or token.startswith("--split-string=")
479
- or env_short_option_uses_split_string(token)
480
- ):
604
+ kind = env_option_kind(token)
605
+ if kind == "split-string":
481
606
  return True
482
- if "=" in token and not token.startswith(("=", "-")):
483
- cursor += 1
484
- continue
485
- if token in ENV_OPTIONS_NO_VALUE or token.startswith(
486
- tuple(
487
- option
488
- for option in ENV_OPTIONS_INLINE_VALUE
489
- if option != "--split-string="
490
- )
491
- ):
492
- cursor += 1
493
- continue
494
- if token in ENV_OPTIONS_SEPARATE_VALUE:
495
- cursor += 2
496
- continue
497
- return False
607
+ if kind == "ambiguous":
608
+ # An option env accepts but this parser cannot name makes the rest
609
+ # of the invocation unreadable; refuse instead of reading past it.
610
+ return True
611
+ if kind is None:
612
+ if "=" in token and not token.startswith(("=", "-")):
613
+ cursor += 1
614
+ continue
615
+ return False
616
+ cursor += 2 if kind == "separate-value" else 1
498
617
  return False
499
618
 
500
619
 
501
620
  def nested_shell_payload(tokens, index):
502
621
  """Return a shell `-c` payload, or True when option parsing is ambiguous."""
503
622
  cursor = index + 1
623
+ opaque_option_seen = False
504
624
  while cursor < len(tokens):
505
625
  option = tokens[cursor]
506
626
  if option in COMMAND_SEPARATORS or option == "--":
507
627
  return None
508
628
  if not option.startswith(("-", "+")):
509
- return None
629
+ # Ordinarily the script operand, which ends option parsing. After
630
+ # a long option this parser cannot name it may instead be that
631
+ # option's value (`zsh --emulate ksh -c ...`), so keep scanning
632
+ # rather than concluding there is no command string.
633
+ if not opaque_option_seen:
634
+ return None
635
+ cursor += 1
636
+ continue
510
637
  if option in SHELL_OPTIONS_SEPARATE_VALUE:
511
638
  if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
512
639
  return True
@@ -515,8 +642,17 @@ def nested_shell_payload(tokens, index):
515
642
  if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
516
643
  cursor += 1
517
644
  continue
518
- body = option[1:]
519
- if "c" in body:
645
+ # A LONG option is never the command-string flag, and testing it for a
646
+ # bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
647
+ # so the real `-c` that followed was consumed as their payload and the
648
+ # nested `git commit --no-verify` was never classified at all. The
649
+ # command string lives only in a single-dash cluster (`-c`, `-lc`).
650
+ if option.startswith("--"):
651
+ if option not in SHELL_LONG_OPTIONS_NO_VALUE:
652
+ opaque_option_seen = True
653
+ cursor += 1
654
+ continue
655
+ if "c" in option[1:]:
520
656
  if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
521
657
  return True
522
658
  return tokens[cursor + 1]