@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.
- package/all/copy-overwrite/scripts/check-orphaned-branches.mjs +218 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +218 -82
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +85 -12
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +74 -15
- package/dist/cli/ui-config-write-persistence.d.ts +12 -2
- package/dist/cli/ui-config-write-persistence.d.ts.map +1 -1
- package/dist/cli/ui-config-write-persistence.js +7 -3
- package/dist/cli/ui-config-write-persistence.js.map +1 -1
- package/dist/cli/ui-config-write.d.ts +1 -1
- package/dist/cli/ui-config-write.d.ts.map +1 -1
- package/dist/cli/ui-config-write.js +62 -6
- package/dist/cli/ui-config-write.js.map +1 -1
- package/dist/codex/scripts/block-no-verify.sh +134 -37
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +16 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- 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 +14 -10
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +218 -82
- 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/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +12 -0
- package/plugins/lisa/hooks/block-no-verify.agy.sh +134 -37
- package/plugins/lisa/hooks/block-no-verify.sh +218 -82
- package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +12 -0
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +134 -37
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +12 -0
- 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-copilot/hooks/block-no-verify.sh +218 -82
- package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +12 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +218 -82
- package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +12 -0
- 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 +134 -37
- package/plugins/src/base/hooks/block-no-verify.sh +218 -82
- package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +12 -0
- package/scripts/check-security-floors.mjs +290 -31
- package/scripts/two-channel-couplings.json +1 -1
- package/typescript/copy-overwrite/.prettierignore +1 -0
- 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
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
399
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
|
436
|
-
"""Whether
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
468
|
-
if
|
|
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
|
-
|
|
477
|
-
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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]
|