@codyswann/lisa 4.34.14 → 4.34.16
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-npm-publish-landed.mjs +250 -0
- package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +3 -5
- package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +20 -3
- package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +33 -8
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +80 -5
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +1 -1
- package/dist/codex/scripts/block-no-verify.sh +80 -5
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +28 -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 +24 -15
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +80 -5
- package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +13 -7
- package/package.json +5 -5
- 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-drive-pr-to-merge/SKILL.md +90 -0
- package/plugins/lisa/hooks/block-direct-issue-create.sh +20 -3
- package/plugins/lisa/hooks/block-managed-file-edits.sh +33 -8
- package/plugins/lisa/hooks/block-no-verify.agy.sh +80 -5
- package/plugins/lisa/hooks/block-no-verify.sh +80 -5
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +20 -3
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +80 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +90 -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-direct-issue-create.sh +20 -3
- package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +33 -8
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +80 -5
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +20 -3
- package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +33 -8
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +80 -5
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +90 -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-direct-issue-create.sh +20 -3
- package/plugins/src/base/hooks/block-managed-file-edits.sh +33 -8
- package/plugins/src/base/hooks/block-no-verify.agy.sh +80 -5
- package/plugins/src/base/hooks/block-no-verify.sh +80 -5
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
- package/scripts/check-npm-publish-landed.mjs +30 -0
- package/scripts/two-channel-couplings.json +17 -4
- package/typescript/copy-overwrite/.claude/hooks/worktree-create.sh +3 -1
- package/typescript/copy-overwrite/.prettierignore +1 -0
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +14 -54
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
# Add a name here in the same commit that closes a vector. A hardening that
|
|
34
34
|
# forgets to is invisible to refresh, and shows up as an unexplained diff at
|
|
35
35
|
# review time instead of a named capability.
|
|
36
|
-
# 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, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position
|
|
36
|
+
# 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, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
|
|
37
37
|
#
|
|
38
38
|
# Shell-token matching avoids false positives from issue bodies, heredocs, and
|
|
39
39
|
# commit-message prose while still catching quoted real argv values such as
|
|
@@ -265,8 +265,38 @@ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
|
|
|
265
265
|
|
|
266
266
|
# env's single-letter options, by whether the rest of a cluster is more
|
|
267
267
|
# options or the option's value.
|
|
268
|
+
#
|
|
269
|
+
# BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
|
|
270
|
+
# in neither set falls through to `ambiguous`, which this guard treats as
|
|
271
|
+
# suspicious. That default is correct and stays — an unknown option genuinely
|
|
272
|
+
# could take a value and swallow the command name. What is wrong is a
|
|
273
|
+
# well-known option being unknown, and on a macOS fleet `env -P` is exactly
|
|
274
|
+
# that: BSD's "search altpath for the utility", refused on every ordinary
|
|
275
|
+
# command it prefixes.
|
|
276
|
+
#
|
|
277
|
+
# Measured, one option at a time, against the guard before this change. BSD's
|
|
278
|
+
# complete short set is `0 i v u C P S`, from the synopsis in env(1):
|
|
279
|
+
#
|
|
280
|
+
# allowed -0 -i -v -u NAME -C DIR already covered
|
|
281
|
+
# BLOCKED -P altpath the defect, and the ONLY one
|
|
282
|
+
# BLOCKED -S string correct: split-string reparses
|
|
283
|
+
# an opaque payload, so what it
|
|
284
|
+
# runs cannot be proven here
|
|
285
|
+
# BLOCKED -Z -Q correct: unknown stays suspicious
|
|
286
|
+
#
|
|
287
|
+
# So the population is one letter, not a class. The two platforms' short
|
|
288
|
+
# options coincide everywhere else, which is why "detect the platform and
|
|
289
|
+
# choose a table" would be machinery for a single character — and would fail
|
|
290
|
+
# on a machine running GNU coreutils under a BSD userland, or the reverse.
|
|
291
|
+
# Recognising the union is both smaller and more robust.
|
|
292
|
+
#
|
|
293
|
+
# Adding a letter here is a WEAKENING, so it needs the same evidence as any
|
|
294
|
+
# other: ten bypass shapes routed through `-P` — long flag, attached value,
|
|
295
|
+
# short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
|
|
296
|
+
# a nested `-c` shell, an executed script, and `-P` swallowing the command
|
|
297
|
+
# word — were all refused before this change and are all still refused after.
|
|
268
298
|
ENV_SHORT_NO_VALUE = frozenset("i0v")
|
|
269
|
-
ENV_SHORT_TAKES_VALUE = frozenset("
|
|
299
|
+
ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
|
|
270
300
|
|
|
271
301
|
# Builtins and wrappers that run whatever FOLLOWS them without changing what
|
|
272
302
|
# it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
|
|
@@ -977,7 +1007,7 @@ def resolve_script(token):
|
|
|
977
1007
|
"""
|
|
978
1008
|
if COMPUTED_VALUE.search(token):
|
|
979
1009
|
return (None, "a computed path the guard cannot resolve before the shell does")
|
|
980
|
-
text = token.strip().strip("'\"")
|
|
1010
|
+
text = os.path.expanduser(token.strip().strip("'\""))
|
|
981
1011
|
# `bash -` and a bare `-` read the script from stdin; there is no file.
|
|
982
1012
|
if not text or text == "-":
|
|
983
1013
|
return (None, None)
|
|
@@ -999,6 +1029,48 @@ def resolve_script(token):
|
|
|
999
1029
|
return (None, "a script that does not exist or cannot be read")
|
|
1000
1030
|
|
|
1001
1031
|
|
|
1032
|
+
SHELL_COMMAND_STRING = object()
|
|
1033
|
+
SHELL_STDIN = object()
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def shell_execution_operand(args):
|
|
1037
|
+
"""The file a shell runs, or a sentinel for `-c` and stdin modes.
|
|
1038
|
+
|
|
1039
|
+
`shell_script_operand` intentionally collapses both cases to None for the
|
|
1040
|
+
nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
|
|
1041
|
+
its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
|
|
1042
|
+
stdin and must remain linked to a following `< file` separator.
|
|
1043
|
+
"""
|
|
1044
|
+
index = 0
|
|
1045
|
+
opaque_option_seen = False
|
|
1046
|
+
while index < len(args):
|
|
1047
|
+
token = args[index]
|
|
1048
|
+
if token == "--":
|
|
1049
|
+
index += 1
|
|
1050
|
+
break
|
|
1051
|
+
if not token.startswith(("-", "+")):
|
|
1052
|
+
if not opaque_option_seen:
|
|
1053
|
+
break
|
|
1054
|
+
index += 1
|
|
1055
|
+
opaque_option_seen = False
|
|
1056
|
+
continue
|
|
1057
|
+
if token in SHELL_OPTIONS_SEPARATE_VALUE:
|
|
1058
|
+
index += 2
|
|
1059
|
+
continue
|
|
1060
|
+
if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
|
|
1061
|
+
index += 1
|
|
1062
|
+
continue
|
|
1063
|
+
if token.startswith("--"):
|
|
1064
|
+
if token not in SHELL_LONG_OPTIONS_NO_VALUE:
|
|
1065
|
+
opaque_option_seen = True
|
|
1066
|
+
index += 1
|
|
1067
|
+
continue
|
|
1068
|
+
if "c" in token[1:]:
|
|
1069
|
+
return SHELL_COMMAND_STRING
|
|
1070
|
+
index += 1
|
|
1071
|
+
return args[index] if index < len(args) else SHELL_STDIN
|
|
1072
|
+
|
|
1073
|
+
|
|
1002
1074
|
def executed_scripts(tokens):
|
|
1003
1075
|
"""The files a command line will RUN, and the executions it cannot follow.
|
|
1004
1076
|
|
|
@@ -1033,9 +1105,12 @@ def executed_scripts(tokens):
|
|
|
1033
1105
|
if name in SOURCE_BUILTINS:
|
|
1034
1106
|
operand = arguments[0] if arguments else None
|
|
1035
1107
|
elif name in SHELL_PROGRAMS:
|
|
1036
|
-
operand =
|
|
1037
|
-
if operand is
|
|
1108
|
+
operand = shell_execution_operand(arguments)
|
|
1109
|
+
if operand is SHELL_STDIN:
|
|
1038
1110
|
previous_shell_awaiting_stdin = True
|
|
1111
|
+
continue
|
|
1112
|
+
if operand is SHELL_COMMAND_STRING:
|
|
1113
|
+
continue
|
|
1039
1114
|
else:
|
|
1040
1115
|
continue
|
|
1041
1116
|
if operand is None:
|
|
@@ -460,6 +460,50 @@ passed off as success.
|
|
|
460
460
|
`blocked:unreviewed`. This fallback lives inside the gated section above —
|
|
461
461
|
with `auto_merge=false` it never fires; the PR remains open awaiting a human.
|
|
462
462
|
|
|
463
|
+
### The mergeability gate — never arm a PR that cannot merge
|
|
464
|
+
|
|
465
|
+
The arm gate above asks *did the review context do work?* This one asks a
|
|
466
|
+
different question — *is there a verdict standing in the way?* — and they are
|
|
467
|
+
not the same. A `CHANGES_REQUESTED` review unambiguously **did work**, so it
|
|
468
|
+
sails through the arm gate and the latch goes on over the top of it.
|
|
469
|
+
|
|
470
|
+
**Read `reviewDecision` explicitly. Never infer it from a check count.**
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
gh pr view <pr> --json reviewDecision,reviewThreads \
|
|
474
|
+
--jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
|
|
478
|
+
this stayed invisible: a failing-check count reads **zero** on a PR that can
|
|
479
|
+
never merge, and the checks tab is entirely green. Any rule keyed on "no red
|
|
480
|
+
checks" is satisfied by exactly the PR this gate exists to catch (#3720).
|
|
481
|
+
|
|
482
|
+
**Do not arm while `reviewDecision == CHANGES_REQUESTED`.** Arming is a claim
|
|
483
|
+
that this PR will merge, and that claim is false here. Clear the verdict through
|
|
484
|
+
step (e) first — which discriminates a live objection from a stranded one by
|
|
485
|
+
unresolved thread count, not by `reviewDecision` — and arm afterwards.
|
|
486
|
+
|
|
487
|
+
**Two ruleset settings decide whether a standing verdict ever clears itself, and
|
|
488
|
+
in this repository both say no.** Verify them for the repo you are in rather
|
|
489
|
+
than assuming, with `gh api repos/<owner>/<repo>/rulesets`:
|
|
490
|
+
|
|
491
|
+
- `dismiss_stale_reviews_on_push: false` — the review stays attached to the
|
|
492
|
+
commit it was made on, so **pushing the fix never clears it**. The intuition
|
|
493
|
+
that a new head resets the verdict is wrong here.
|
|
494
|
+
- `required_approving_review_count: 0` — no approval is required to merge, so a
|
|
495
|
+
standing `CHANGES_REQUESTED` is not holding a slot for a review that would
|
|
496
|
+
otherwise arrive. Nothing is scheduled to clear it.
|
|
497
|
+
|
|
498
|
+
Together those mean the block is **indefinite, not slow**. Waiting is not a
|
|
499
|
+
strategy; something has to act.
|
|
500
|
+
|
|
501
|
+
**Unresolved threads block independently of the verdict.** This repository sets
|
|
502
|
+
`required_review_thread_resolution: true`, so a PR can have a clear
|
|
503
|
+
`reviewDecision` and still be unmergeable on threads alone — a second blocker
|
|
504
|
+
that is equally absent from the check rollup. Read both, and name whichever one
|
|
505
|
+
is standing; reporting the wrong one sends the operator to the wrong place.
|
|
506
|
+
|
|
463
507
|
## 2. The watch loop
|
|
464
508
|
|
|
465
509
|
Poll the live state each iteration:
|
|
@@ -1078,6 +1122,14 @@ Loop until one of:
|
|
|
1078
1122
|
the one thing nobody would then do is investigate it. Usually a missing
|
|
1079
1123
|
`issues: read` / label-read permission, which is fixable and invisible from
|
|
1080
1124
|
the merge outcome alone.
|
|
1125
|
+
- **`blocked:armed-unmergeable`** — the PR is OPEN, auto-merge is ON, every check
|
|
1126
|
+
is green, and it still cannot merge: `reviewDecision == CHANGES_REQUESTED`, or
|
|
1127
|
+
unresolved review threads under `required_review_thread_resolution`. Report the
|
|
1128
|
+
PR URL, **which** of the two is standing, and that it will not clear itself.
|
|
1129
|
+
For example: *"PR #123 looks ready — every check is green and auto-merge is on
|
|
1130
|
+
— but a review asked for changes and that is still on record, so it will sit
|
|
1131
|
+
there forever. Someone needs to clear that review or resolve the open
|
|
1132
|
+
comments."* Never report this PR as merging.
|
|
1081
1133
|
- **`CLOSED`** → report (PR was closed without merge).
|
|
1082
1134
|
- **Hard block needing a human**: an unresolvable conflict, a failing check that
|
|
1083
1135
|
needs design input, or genuine unresolved human objection (not a bot gate). Stop
|
|
@@ -1087,6 +1139,44 @@ Loop until one of:
|
|
|
1087
1139
|
adversarial-review substitute is posted (step d); it never extends to any
|
|
1088
1140
|
other gate.
|
|
1089
1141
|
|
|
1142
|
+
### Before you stop: never leave a PR armed and unmergeable in silence
|
|
1143
|
+
|
|
1144
|
+
**This applies at every exit that leaves the PR OPEN** — not only the terminal
|
|
1145
|
+
states above, but any point where this run concludes it is finished, hands back,
|
|
1146
|
+
or gives up. Re-read, from the live PR, immediately before reporting:
|
|
1147
|
+
|
|
1148
|
+
```bash
|
|
1149
|
+
gh pr view <pr> --json state,autoMergeRequest,reviewDecision,reviewThreads \
|
|
1150
|
+
--jq '{state, armed: (.autoMergeRequest != null), decision: .reviewDecision,
|
|
1151
|
+
unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
If `state == OPEN` and `armed` and either `decision == "CHANGES_REQUESTED"` or
|
|
1155
|
+
`unresolved > 0`, the outcome is **`blocked:armed-unmergeable`** — never a
|
|
1156
|
+
success, and never silence.
|
|
1157
|
+
|
|
1158
|
+
**Why this exists rather than trusting the loop.** While the loop runs this is
|
|
1159
|
+
already covered: the watch poll reads `reviewDecision` and step (e) handles it.
|
|
1160
|
+
The failure is a run that **stops** while the PR is armed and blocked — the
|
|
1161
|
+
session ends, or the agent decides the work is done because arming looked like
|
|
1162
|
+
completion. Measured (#3720): three PRs were left armed, green and permanently
|
|
1163
|
+
unmergeable, and every one was found only because a person queried
|
|
1164
|
+
`reviewDecision` by hand. An agent that arms auto-merge and reports "armed, will
|
|
1165
|
+
merge" is reporting something false and has no reason to know it.
|
|
1166
|
+
|
|
1167
|
+
**Arming is not completion.** It is a claim about the future that this check is
|
|
1168
|
+
what makes honest. The claim costs nothing when true — one read, and the report
|
|
1169
|
+
says merging — and when false it is the only thing standing between a parked PR
|
|
1170
|
+
and nobody noticing for hours.
|
|
1171
|
+
|
|
1172
|
+
**Read the two fields; do not substitute a check count.** Zero failing checks is
|
|
1173
|
+
the symptom, not the signal — that is precisely why this class was invisible.
|
|
1174
|
+
|
|
1175
|
+
In `on_blocker=report` mode the same condition returns the existing
|
|
1176
|
+
`blocked:changes_requested` classification rather than this terminal state: that
|
|
1177
|
+
mode returns a blocker for the caller to act on, and does not drive. The two
|
|
1178
|
+
names describe the same PR seen from the two modes.
|
|
1179
|
+
|
|
1090
1180
|
At every terminal state, release the babysitter lease
|
|
1091
1181
|
(`gh pr edit <pr> --remove-label "lisa:babysitter-on-duty"`) so the CI
|
|
1092
1182
|
auto-fix workflow can take over as fixer of last resort if the branch goes
|
|
@@ -1432,8 +1432,20 @@ EXEC_WRAPPERS = {
|
|
|
1432
1432
|
}
|
|
1433
1433
|
|
|
1434
1434
|
# Interpreter options that mean "no script file follows": a command string or a
|
|
1435
|
-
# module name, both of which the guard reads by other means or not at all.
|
|
1436
|
-
|
|
1435
|
+
# module name, both of which the guard reads by other means or not at all. These
|
|
1436
|
+
# are interpreter-specific on purpose. `bash -e create.sh` and `sh -m create.sh`
|
|
1437
|
+
# still execute the following file; treating Python and Node flags as universal
|
|
1438
|
+
# silently stopped the scan before those shell operands.
|
|
1439
|
+
NO_SCRIPT_OPTIONS = {
|
|
1440
|
+
"bun": {"-e", "--eval"},
|
|
1441
|
+
"node": {"-e", "--eval"},
|
|
1442
|
+
"nodejs": {"-e", "--eval"},
|
|
1443
|
+
"perl": {"-e"},
|
|
1444
|
+
"php": {"-r"},
|
|
1445
|
+
"python": {"-c", "-m", "--command", "--module"},
|
|
1446
|
+
"python3": {"-c", "-m", "--command", "--module"},
|
|
1447
|
+
"ruby": {"-e"},
|
|
1448
|
+
}
|
|
1437
1449
|
|
|
1438
1450
|
# Programs that READ their operands and never execute them as programs.
|
|
1439
1451
|
#
|
|
@@ -1569,7 +1581,12 @@ def executed_operand(argv):
|
|
|
1569
1581
|
if argument.startswith("-"):
|
|
1570
1582
|
# A command string or a module name; no script file follows.
|
|
1571
1583
|
head = argument.split("=", 1)[0]
|
|
1572
|
-
if
|
|
1584
|
+
if program in {"bash", "dash", "ksh", "sh", "zsh"}:
|
|
1585
|
+
# Shell `-c` may be clustered (`-ec`), while `-e` and `-m` are
|
|
1586
|
+
# ordinary shell options and do not consume the script path.
|
|
1587
|
+
if not head.startswith("--") and "c" in head[1:]:
|
|
1588
|
+
return None
|
|
1589
|
+
elif head in NO_SCRIPT_OPTIONS.get(program, set()):
|
|
1573
1590
|
return None
|
|
1574
1591
|
continue
|
|
1575
1592
|
return argument
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
# not JSON). Malformed/empty stdin → allow (fail open, never crash the tool).
|
|
29
29
|
#
|
|
30
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, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position
|
|
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, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
|
|
32
32
|
set -uo pipefail
|
|
33
33
|
|
|
34
34
|
allow() {
|
|
@@ -195,8 +195,38 @@ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
|
|
|
195
195
|
|
|
196
196
|
# env's single-letter options, by whether the rest of a cluster is more
|
|
197
197
|
# options or the option's value.
|
|
198
|
+
#
|
|
199
|
+
# BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
|
|
200
|
+
# in neither set falls through to `ambiguous`, which this guard treats as
|
|
201
|
+
# suspicious. That default is correct and stays — an unknown option genuinely
|
|
202
|
+
# could take a value and swallow the command name. What is wrong is a
|
|
203
|
+
# well-known option being unknown, and on a macOS fleet `env -P` is exactly
|
|
204
|
+
# that: BSD's "search altpath for the utility", refused on every ordinary
|
|
205
|
+
# command it prefixes.
|
|
206
|
+
#
|
|
207
|
+
# Measured, one option at a time, against the guard before this change. BSD's
|
|
208
|
+
# complete short set is `0 i v u C P S`, from the synopsis in env(1):
|
|
209
|
+
#
|
|
210
|
+
# allowed -0 -i -v -u NAME -C DIR already covered
|
|
211
|
+
# BLOCKED -P altpath the defect, and the ONLY one
|
|
212
|
+
# BLOCKED -S string correct: split-string reparses
|
|
213
|
+
# an opaque payload, so what it
|
|
214
|
+
# runs cannot be proven here
|
|
215
|
+
# BLOCKED -Z -Q correct: unknown stays suspicious
|
|
216
|
+
#
|
|
217
|
+
# So the population is one letter, not a class. The two platforms' short
|
|
218
|
+
# options coincide everywhere else, which is why "detect the platform and
|
|
219
|
+
# choose a table" would be machinery for a single character — and would fail
|
|
220
|
+
# on a machine running GNU coreutils under a BSD userland, or the reverse.
|
|
221
|
+
# Recognising the union is both smaller and more robust.
|
|
222
|
+
#
|
|
223
|
+
# Adding a letter here is a WEAKENING, so it needs the same evidence as any
|
|
224
|
+
# other: ten bypass shapes routed through `-P` — long flag, attached value,
|
|
225
|
+
# short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
|
|
226
|
+
# a nested `-c` shell, an executed script, and `-P` swallowing the command
|
|
227
|
+
# word — were all refused before this change and are all still refused after.
|
|
198
228
|
ENV_SHORT_NO_VALUE = frozenset("i0v")
|
|
199
|
-
ENV_SHORT_TAKES_VALUE = frozenset("
|
|
229
|
+
ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
|
|
200
230
|
|
|
201
231
|
# Builtins and wrappers that run whatever FOLLOWS them without changing what
|
|
202
232
|
# it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
|
|
@@ -907,7 +937,7 @@ def resolve_script(token):
|
|
|
907
937
|
"""
|
|
908
938
|
if COMPUTED_VALUE.search(token):
|
|
909
939
|
return (None, "a computed path the guard cannot resolve before the shell does")
|
|
910
|
-
text = token.strip().strip("'\"")
|
|
940
|
+
text = os.path.expanduser(token.strip().strip("'\""))
|
|
911
941
|
# `bash -` and a bare `-` read the script from stdin; there is no file.
|
|
912
942
|
if not text or text == "-":
|
|
913
943
|
return (None, None)
|
|
@@ -929,6 +959,48 @@ def resolve_script(token):
|
|
|
929
959
|
return (None, "a script that does not exist or cannot be read")
|
|
930
960
|
|
|
931
961
|
|
|
962
|
+
SHELL_COMMAND_STRING = object()
|
|
963
|
+
SHELL_STDIN = object()
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def shell_execution_operand(args):
|
|
967
|
+
"""The file a shell runs, or a sentinel for `-c` and stdin modes.
|
|
968
|
+
|
|
969
|
+
`shell_script_operand` intentionally collapses both cases to None for the
|
|
970
|
+
nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
|
|
971
|
+
its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
|
|
972
|
+
stdin and must remain linked to a following `< file` separator.
|
|
973
|
+
"""
|
|
974
|
+
index = 0
|
|
975
|
+
opaque_option_seen = False
|
|
976
|
+
while index < len(args):
|
|
977
|
+
token = args[index]
|
|
978
|
+
if token == "--":
|
|
979
|
+
index += 1
|
|
980
|
+
break
|
|
981
|
+
if not token.startswith(("-", "+")):
|
|
982
|
+
if not opaque_option_seen:
|
|
983
|
+
break
|
|
984
|
+
index += 1
|
|
985
|
+
opaque_option_seen = False
|
|
986
|
+
continue
|
|
987
|
+
if token in SHELL_OPTIONS_SEPARATE_VALUE:
|
|
988
|
+
index += 2
|
|
989
|
+
continue
|
|
990
|
+
if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
|
|
991
|
+
index += 1
|
|
992
|
+
continue
|
|
993
|
+
if token.startswith("--"):
|
|
994
|
+
if token not in SHELL_LONG_OPTIONS_NO_VALUE:
|
|
995
|
+
opaque_option_seen = True
|
|
996
|
+
index += 1
|
|
997
|
+
continue
|
|
998
|
+
if "c" in token[1:]:
|
|
999
|
+
return SHELL_COMMAND_STRING
|
|
1000
|
+
index += 1
|
|
1001
|
+
return args[index] if index < len(args) else SHELL_STDIN
|
|
1002
|
+
|
|
1003
|
+
|
|
932
1004
|
def executed_scripts(tokens):
|
|
933
1005
|
"""The files a command line will RUN, and the executions it cannot follow.
|
|
934
1006
|
|
|
@@ -963,9 +1035,12 @@ def executed_scripts(tokens):
|
|
|
963
1035
|
if name in SOURCE_BUILTINS:
|
|
964
1036
|
operand = arguments[0] if arguments else None
|
|
965
1037
|
elif name in SHELL_PROGRAMS:
|
|
966
|
-
operand =
|
|
967
|
-
if operand is
|
|
1038
|
+
operand = shell_execution_operand(arguments)
|
|
1039
|
+
if operand is SHELL_STDIN:
|
|
968
1040
|
previous_shell_awaiting_stdin = True
|
|
1041
|
+
continue
|
|
1042
|
+
if operand is SHELL_COMMAND_STRING:
|
|
1043
|
+
continue
|
|
969
1044
|
else:
|
|
970
1045
|
continue
|
|
971
1046
|
if operand is None:
|
|
@@ -460,6 +460,50 @@ passed off as success.
|
|
|
460
460
|
`blocked:unreviewed`. This fallback lives inside the gated section above —
|
|
461
461
|
with `auto_merge=false` it never fires; the PR remains open awaiting a human.
|
|
462
462
|
|
|
463
|
+
### The mergeability gate — never arm a PR that cannot merge
|
|
464
|
+
|
|
465
|
+
The arm gate above asks *did the review context do work?* This one asks a
|
|
466
|
+
different question — *is there a verdict standing in the way?* — and they are
|
|
467
|
+
not the same. A `CHANGES_REQUESTED` review unambiguously **did work**, so it
|
|
468
|
+
sails through the arm gate and the latch goes on over the top of it.
|
|
469
|
+
|
|
470
|
+
**Read `reviewDecision` explicitly. Never infer it from a check count.**
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
gh pr view <pr> --json reviewDecision,reviewThreads \
|
|
474
|
+
--jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
|
|
478
|
+
this stayed invisible: a failing-check count reads **zero** on a PR that can
|
|
479
|
+
never merge, and the checks tab is entirely green. Any rule keyed on "no red
|
|
480
|
+
checks" is satisfied by exactly the PR this gate exists to catch (#3720).
|
|
481
|
+
|
|
482
|
+
**Do not arm while `reviewDecision == CHANGES_REQUESTED`.** Arming is a claim
|
|
483
|
+
that this PR will merge, and that claim is false here. Clear the verdict through
|
|
484
|
+
step (e) first — which discriminates a live objection from a stranded one by
|
|
485
|
+
unresolved thread count, not by `reviewDecision` — and arm afterwards.
|
|
486
|
+
|
|
487
|
+
**Two ruleset settings decide whether a standing verdict ever clears itself, and
|
|
488
|
+
in this repository both say no.** Verify them for the repo you are in rather
|
|
489
|
+
than assuming, with `gh api repos/<owner>/<repo>/rulesets`:
|
|
490
|
+
|
|
491
|
+
- `dismiss_stale_reviews_on_push: false` — the review stays attached to the
|
|
492
|
+
commit it was made on, so **pushing the fix never clears it**. The intuition
|
|
493
|
+
that a new head resets the verdict is wrong here.
|
|
494
|
+
- `required_approving_review_count: 0` — no approval is required to merge, so a
|
|
495
|
+
standing `CHANGES_REQUESTED` is not holding a slot for a review that would
|
|
496
|
+
otherwise arrive. Nothing is scheduled to clear it.
|
|
497
|
+
|
|
498
|
+
Together those mean the block is **indefinite, not slow**. Waiting is not a
|
|
499
|
+
strategy; something has to act.
|
|
500
|
+
|
|
501
|
+
**Unresolved threads block independently of the verdict.** This repository sets
|
|
502
|
+
`required_review_thread_resolution: true`, so a PR can have a clear
|
|
503
|
+
`reviewDecision` and still be unmergeable on threads alone — a second blocker
|
|
504
|
+
that is equally absent from the check rollup. Read both, and name whichever one
|
|
505
|
+
is standing; reporting the wrong one sends the operator to the wrong place.
|
|
506
|
+
|
|
463
507
|
## 2. The watch loop
|
|
464
508
|
|
|
465
509
|
Poll the live state each iteration:
|
|
@@ -1078,6 +1122,14 @@ Loop until one of:
|
|
|
1078
1122
|
the one thing nobody would then do is investigate it. Usually a missing
|
|
1079
1123
|
`issues: read` / label-read permission, which is fixable and invisible from
|
|
1080
1124
|
the merge outcome alone.
|
|
1125
|
+
- **`blocked:armed-unmergeable`** — the PR is OPEN, auto-merge is ON, every check
|
|
1126
|
+
is green, and it still cannot merge: `reviewDecision == CHANGES_REQUESTED`, or
|
|
1127
|
+
unresolved review threads under `required_review_thread_resolution`. Report the
|
|
1128
|
+
PR URL, **which** of the two is standing, and that it will not clear itself.
|
|
1129
|
+
For example: *"PR #123 looks ready — every check is green and auto-merge is on
|
|
1130
|
+
— but a review asked for changes and that is still on record, so it will sit
|
|
1131
|
+
there forever. Someone needs to clear that review or resolve the open
|
|
1132
|
+
comments."* Never report this PR as merging.
|
|
1081
1133
|
- **`CLOSED`** → report (PR was closed without merge).
|
|
1082
1134
|
- **Hard block needing a human**: an unresolvable conflict, a failing check that
|
|
1083
1135
|
needs design input, or genuine unresolved human objection (not a bot gate). Stop
|
|
@@ -1087,6 +1139,44 @@ Loop until one of:
|
|
|
1087
1139
|
adversarial-review substitute is posted (step d); it never extends to any
|
|
1088
1140
|
other gate.
|
|
1089
1141
|
|
|
1142
|
+
### Before you stop: never leave a PR armed and unmergeable in silence
|
|
1143
|
+
|
|
1144
|
+
**This applies at every exit that leaves the PR OPEN** — not only the terminal
|
|
1145
|
+
states above, but any point where this run concludes it is finished, hands back,
|
|
1146
|
+
or gives up. Re-read, from the live PR, immediately before reporting:
|
|
1147
|
+
|
|
1148
|
+
```bash
|
|
1149
|
+
gh pr view <pr> --json state,autoMergeRequest,reviewDecision,reviewThreads \
|
|
1150
|
+
--jq '{state, armed: (.autoMergeRequest != null), decision: .reviewDecision,
|
|
1151
|
+
unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
If `state == OPEN` and `armed` and either `decision == "CHANGES_REQUESTED"` or
|
|
1155
|
+
`unresolved > 0`, the outcome is **`blocked:armed-unmergeable`** — never a
|
|
1156
|
+
success, and never silence.
|
|
1157
|
+
|
|
1158
|
+
**Why this exists rather than trusting the loop.** While the loop runs this is
|
|
1159
|
+
already covered: the watch poll reads `reviewDecision` and step (e) handles it.
|
|
1160
|
+
The failure is a run that **stops** while the PR is armed and blocked — the
|
|
1161
|
+
session ends, or the agent decides the work is done because arming looked like
|
|
1162
|
+
completion. Measured (#3720): three PRs were left armed, green and permanently
|
|
1163
|
+
unmergeable, and every one was found only because a person queried
|
|
1164
|
+
`reviewDecision` by hand. An agent that arms auto-merge and reports "armed, will
|
|
1165
|
+
merge" is reporting something false and has no reason to know it.
|
|
1166
|
+
|
|
1167
|
+
**Arming is not completion.** It is a claim about the future that this check is
|
|
1168
|
+
what makes honest. The claim costs nothing when true — one read, and the report
|
|
1169
|
+
says merging — and when false it is the only thing standing between a parked PR
|
|
1170
|
+
and nobody noticing for hours.
|
|
1171
|
+
|
|
1172
|
+
**Read the two fields; do not substitute a check count.** Zero failing checks is
|
|
1173
|
+
the symptom, not the signal — that is precisely why this class was invisible.
|
|
1174
|
+
|
|
1175
|
+
In `on_blocker=report` mode the same condition returns the existing
|
|
1176
|
+
`blocked:changes_requested` classification rather than this terminal state: that
|
|
1177
|
+
mode returns a blocker for the caller to act on, and does not drive. The two
|
|
1178
|
+
names describe the same PR seen from the two modes.
|
|
1179
|
+
|
|
1090
1180
|
At every terminal state, release the babysitter lease
|
|
1091
1181
|
(`gh pr edit <pr> --remove-label "lisa:babysitter-on-duty"`) so the CI
|
|
1092
1182
|
auto-fix workflow can take over as fixer of last resort if the branch goes
|
|
@@ -1432,8 +1432,20 @@ EXEC_WRAPPERS = {
|
|
|
1432
1432
|
}
|
|
1433
1433
|
|
|
1434
1434
|
# Interpreter options that mean "no script file follows": a command string or a
|
|
1435
|
-
# module name, both of which the guard reads by other means or not at all.
|
|
1436
|
-
|
|
1435
|
+
# module name, both of which the guard reads by other means or not at all. These
|
|
1436
|
+
# are interpreter-specific on purpose. `bash -e create.sh` and `sh -m create.sh`
|
|
1437
|
+
# still execute the following file; treating Python and Node flags as universal
|
|
1438
|
+
# silently stopped the scan before those shell operands.
|
|
1439
|
+
NO_SCRIPT_OPTIONS = {
|
|
1440
|
+
"bun": {"-e", "--eval"},
|
|
1441
|
+
"node": {"-e", "--eval"},
|
|
1442
|
+
"nodejs": {"-e", "--eval"},
|
|
1443
|
+
"perl": {"-e"},
|
|
1444
|
+
"php": {"-r"},
|
|
1445
|
+
"python": {"-c", "-m", "--command", "--module"},
|
|
1446
|
+
"python3": {"-c", "-m", "--command", "--module"},
|
|
1447
|
+
"ruby": {"-e"},
|
|
1448
|
+
}
|
|
1437
1449
|
|
|
1438
1450
|
# Programs that READ their operands and never execute them as programs.
|
|
1439
1451
|
#
|
|
@@ -1569,7 +1581,12 @@ def executed_operand(argv):
|
|
|
1569
1581
|
if argument.startswith("-"):
|
|
1570
1582
|
# A command string or a module name; no script file follows.
|
|
1571
1583
|
head = argument.split("=", 1)[0]
|
|
1572
|
-
if
|
|
1584
|
+
if program in {"bash", "dash", "ksh", "sh", "zsh"}:
|
|
1585
|
+
# Shell `-c` may be clustered (`-ec`), while `-e` and `-m` are
|
|
1586
|
+
# ordinary shell options and do not consume the script path.
|
|
1587
|
+
if not head.startswith("--") and "c" in head[1:]:
|
|
1588
|
+
return None
|
|
1589
|
+
elif head in NO_SCRIPT_OPTIONS.get(program, set()):
|
|
1573
1590
|
return None
|
|
1574
1591
|
continue
|
|
1575
1592
|
return argument
|