@codyswann/lisa 4.24.2 → 4.25.1
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/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 +8 -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 +7 -6
- 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/hooks/block-no-verify.agy.sh +134 -37
- package/plugins/lisa/hooks/block-no-verify.sh +218 -82
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +134 -37
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +218 -82
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +218 -82
- 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/ui/README.md +30 -2
|
@@ -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]
|
|
@@ -673,8 +673,10 @@ export function readLivePolicy({ repo, gh }) {
|
|
|
673
673
|
* One record per declared (ruleset, context) pair, from `declaredChecks`.
|
|
674
674
|
* Supplied, it is what the comparison is made of; absent, the name-keyed
|
|
675
675
|
* `homes`/`pins` projection is reconstituted into equivalent records.
|
|
676
|
-
* @param {Array<{context:string, ruleset?:string}>} [options.awaited]
|
|
677
|
-
* Awaited declarations, preserving their ruleset identity.
|
|
676
|
+
* @param {Array<{context:string, ruleset?:string, integration_id?:number}>} [options.awaited]
|
|
677
|
+
* Awaited declarations, preserving their ruleset identity and declared pin.
|
|
678
|
+
* A declaration carrying a ruleset becomes a comparison record of its own,
|
|
679
|
+
* unless `records` already declares that same (ruleset, context) pair.
|
|
678
680
|
* @returns {ContextDrift} Three sets, plus the unsatisfied records.
|
|
679
681
|
*/
|
|
680
682
|
export function reconcileContexts({
|
|
@@ -711,6 +713,37 @@ export function reconcileContexts({
|
|
|
711
713
|
if (!byContext.has(record.context)) byContext.set(record.context, []);
|
|
712
714
|
byContext.get(record.context).push(record);
|
|
713
715
|
}
|
|
716
|
+
// An awaited declaration names a ruleset too — the home `awaitedHome`
|
|
717
|
+
// resolved, which is where its repair will be written. It has to reach the
|
|
718
|
+
// records the comparison is made of for exactly the reason a configured
|
|
719
|
+
// record does: without it, an awaited context required in `release` answered
|
|
720
|
+
// for the requirement `base` was missing, the pair read as matched, and the
|
|
721
|
+
// repair that would have added it was never planned.
|
|
722
|
+
for (const record of awaitedDeclarations) {
|
|
723
|
+
if (!declaredNames.has(record?.context)) continue;
|
|
724
|
+
// No home could be resolved — an ambiguous repository, or an explicit
|
|
725
|
+
// `--ruleset` naming one that is not here. Name-only matching is retained
|
|
726
|
+
// there on purpose: tightening would report drift against a ruleset nobody
|
|
727
|
+
// chose, and the repair has no place to write it either.
|
|
728
|
+
if (record.ruleset === undefined || record.ruleset === null) continue;
|
|
729
|
+
if (!byContext.has(record.context)) byContext.set(record.context, []);
|
|
730
|
+
const declarationsFor = byContext.get(record.context);
|
|
731
|
+
// An explicit `requiredChecks` declaration for the same pair is the one the
|
|
732
|
+
// project wrote down, and it stays in charge — including of its pin. Adding
|
|
733
|
+
// the awaited record beside it would state two expectations for one
|
|
734
|
+
// (ruleset, context) pair, and a pinned declaration and an unpinned one can
|
|
735
|
+
// never both be satisfied: the pair would be permanently missing.
|
|
736
|
+
if (declarationsFor.some(entry => entry.ruleset === record.ruleset)) {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
declarationsFor.push({
|
|
740
|
+
context: record.context,
|
|
741
|
+
ruleset: record.ruleset,
|
|
742
|
+
...(Number.isInteger(record.integration_id)
|
|
743
|
+
? { integration_id: record.integration_id }
|
|
744
|
+
: {}),
|
|
745
|
+
});
|
|
746
|
+
}
|
|
714
747
|
// A gate-derived context names no ruleset and no app, so it keeps matching on
|
|
715
748
|
// name against any active ruleset; nothing is tightened that was never stated.
|
|
716
749
|
const declarations = [...declaredNames]
|
|
@@ -1243,7 +1276,7 @@ function planContextRepairs({
|
|
|
1243
1276
|
pins,
|
|
1244
1277
|
homes,
|
|
1245
1278
|
}) {
|
|
1246
|
-
/** @type {Map<string, {add: string[], remove: string[], pins: Record<string, number>, unpinned: Set<string>}>} */
|
|
1279
|
+
/** @type {Map<string, {add: string[], remove: string[], pins: Record<string, number>, unpinned: Set<string>, awaited: Set<string>}>} */
|
|
1247
1280
|
const groups = new Map();
|
|
1248
1281
|
const group = name => {
|
|
1249
1282
|
if (!groups.has(name)) {
|
|
@@ -1252,11 +1285,26 @@ function planContextRepairs({
|
|
|
1252
1285
|
remove: [],
|
|
1253
1286
|
pins: {},
|
|
1254
1287
|
unpinned: new Set(),
|
|
1288
|
+
// Which of THIS ruleset's additions are awaited. A declaration that
|
|
1289
|
+
// resolved a home of its own is matched by name below; this set is for
|
|
1290
|
+
// the ones that did not, whose home only becomes knowable here.
|
|
1291
|
+
awaited: new Set(),
|
|
1255
1292
|
});
|
|
1256
1293
|
}
|
|
1257
1294
|
return groups.get(name);
|
|
1258
1295
|
};
|
|
1259
1296
|
const problems = [];
|
|
1297
|
+
// An awaited declaration with no ruleset resolved no home of its own, yet it
|
|
1298
|
+
// is still routed somewhere — through `homes`, or through the fallback. The
|
|
1299
|
+
// payload matched awaited records against the target by their OWN ruleset,
|
|
1300
|
+
// so these were dropped from the awaited list and written with the Actions
|
|
1301
|
+
// pin: the one writer that can never post an external check, which leaves the
|
|
1302
|
+
// repaired rule permanently unsatisfiable. Record where each one lands.
|
|
1303
|
+
const unscopedAwaited = new Set(
|
|
1304
|
+
awaited
|
|
1305
|
+
.filter(record => record.ruleset === undefined || record.ruleset === null)
|
|
1306
|
+
.map(record => record.context)
|
|
1307
|
+
);
|
|
1260
1308
|
|
|
1261
1309
|
// A removal goes to the ruleset that actually requires it. That ruleset is
|
|
1262
1310
|
// reported alongside every EXTRA context precisely so this is knowable.
|
|
@@ -1281,6 +1329,7 @@ function planContextRepairs({
|
|
|
1281
1329
|
const target = home ? group(home) : null;
|
|
1282
1330
|
if (target) {
|
|
1283
1331
|
target.add.push(context);
|
|
1332
|
+
if (unscopedAwaited.has(context)) target.awaited.add(context);
|
|
1284
1333
|
// A declared record is authoritative about its own pin — INCLUDING the
|
|
1285
1334
|
// absence of one. `pins` is name-keyed and last-write-wins, so if `base`
|
|
1286
1335
|
// declares a context unpinned and `release` pins it to app 99, the
|
|
@@ -1303,11 +1352,16 @@ function planContextRepairs({
|
|
|
1303
1352
|
problems.push(fallback.problem);
|
|
1304
1353
|
continue;
|
|
1305
1354
|
}
|
|
1306
|
-
group(fallback.ruleset.name)
|
|
1355
|
+
const fallbackGroup = group(fallback.ruleset.name);
|
|
1356
|
+
fallbackGroup.add.push(context);
|
|
1357
|
+
if (unscopedAwaited.has(context)) fallbackGroup.awaited.add(context);
|
|
1307
1358
|
}
|
|
1308
1359
|
|
|
1309
1360
|
const actions = [];
|
|
1310
|
-
for (const [
|
|
1361
|
+
for (const [
|
|
1362
|
+
name,
|
|
1363
|
+
{ add, remove, pins: groupPins, unpinned, awaited: routedAwaited },
|
|
1364
|
+
] of groups) {
|
|
1311
1365
|
const ruleset = (live.rulesets ?? []).find(entry => entry?.name === name);
|
|
1312
1366
|
if (!ruleset) {
|
|
1313
1367
|
actions.push({
|
|
@@ -1329,9 +1383,17 @@ function planContextRepairs({
|
|
|
1329
1383
|
payload: rulesetPayload(ruleset, {
|
|
1330
1384
|
add,
|
|
1331
1385
|
remove,
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1386
|
+
// Both halves of the same question — which of this ruleset's additions
|
|
1387
|
+
// an external app posts. A declaration that named this ruleset answers
|
|
1388
|
+
// it directly; one that named none was answered by the routing above.
|
|
1389
|
+
awaited: [
|
|
1390
|
+
...new Set([
|
|
1391
|
+
...awaited
|
|
1392
|
+
.filter(record => record.ruleset === name)
|
|
1393
|
+
.map(record => record.context),
|
|
1394
|
+
...routedAwaited,
|
|
1395
|
+
]),
|
|
1396
|
+
],
|
|
1335
1397
|
// The name-keyed map still carries AWAITED pins, which are declared per
|
|
1336
1398
|
// context and have no ruleset of their own, so it stays the base. A
|
|
1337
1399
|
// declared record then overrides it — with its pin, or by deleting the
|
|
@@ -1478,13 +1540,24 @@ export function reconcile({
|
|
|
1478
1540
|
|
|
1479
1541
|
// The structured declaration travels through the comparison, so a pinned
|
|
1480
1542
|
// context is not reported as satisfied by an unpinned one somewhere else.
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1543
|
+
// The awaited-context home is part of that structure, not a repair-only
|
|
1544
|
+
// routing hint: it is the ruleset this run WILL write the context to, so a
|
|
1545
|
+
// comparison that ignored it reported the requirement satisfied by whichever
|
|
1546
|
+
// other ruleset happened to carry the name — and then planned no repair for
|
|
1547
|
+
// the one that lacked it. Where no home resolves, nothing is asserted and the
|
|
1548
|
+
// context keeps matching on name alone, so no drift is invented either.
|
|
1484
1549
|
const resolvedAwaitedHome = awaitedHome(live, rulesetName);
|
|
1550
|
+
const declaredAwaitedPins = awaitedPins(gates, moment);
|
|
1551
|
+
// The declared pin travels with the declaration. Once the awaited home is a
|
|
1552
|
+
// comparison record, that record is what the repair reads its pin off — so
|
|
1553
|
+
// omitting it here would have the repair write the context unpinned and
|
|
1554
|
+
// silently discard the one app the project said may post it.
|
|
1485
1555
|
const awaitedDeclarations = awaited.map(context => ({
|
|
1486
1556
|
context,
|
|
1487
1557
|
...(resolvedAwaitedHome === null ? {} : { ruleset: resolvedAwaitedHome }),
|
|
1558
|
+
...(Object.hasOwn(declaredAwaitedPins, context)
|
|
1559
|
+
? { integration_id: declaredAwaitedPins[context] }
|
|
1560
|
+
: {}),
|
|
1488
1561
|
}));
|
|
1489
1562
|
const contexts = reconcileContexts({
|
|
1490
1563
|
declared,
|
|
@@ -1514,7 +1587,7 @@ export function reconcile({
|
|
|
1514
1587
|
prune,
|
|
1515
1588
|
rulesetName,
|
|
1516
1589
|
awaited: awaitedDeclarations,
|
|
1517
|
-
pins: { ...configured.pins, ...
|
|
1590
|
+
pins: { ...configured.pins, ...declaredAwaitedPins },
|
|
1518
1591
|
// An awaited context has no ruleset in its declaration — it is declared
|
|
1519
1592
|
// on a GATE. Its home defaults to the ruleset Lisa generates from
|
|
1520
1593
|
// config, which is where the applier writes it, so the two writers
|
|
@@ -2508,30 +2508,89 @@ function option(args, name, envName) {
|
|
|
2508
2508
|
}
|
|
2509
2509
|
|
|
2510
2510
|
/**
|
|
2511
|
-
*
|
|
2512
|
-
*
|
|
2511
|
+
* Read one explicit pull-request alias, refusing a present-but-empty flag.
|
|
2512
|
+
*
|
|
2513
|
+
* Written as "absent, or a usable value" — never "absent, or absent-looking".
|
|
2514
|
+
* The shared `option` helper treats a valueless flag as absent so a good value
|
|
2515
|
+
* in the environment still wins, which is right for `--ref` and wrong here: an
|
|
2516
|
+
* operator who typed `--pr-url` has NAMED the evidence, and quietly answering
|
|
2517
|
+
* with `LISA_PR_URL` writes a backlink to whatever pull request the surrounding
|
|
2518
|
+
* automation happened to export. Measured before this refusal existed:
|
|
2519
|
+
* `backlink --ref <item> --pr-url --json` with `LISA_PR_URL` set to a different
|
|
2520
|
+
* pull request wrote the managed comment pointing at the environment's pull
|
|
2521
|
+
* request and exited 0, so the traceability gate went green having proved the
|
|
2522
|
+
* work item was linked to the wrong change.
|
|
2523
|
+
*
|
|
2524
|
+
* A present flag whose value is empty, whitespace, or another option is the
|
|
2525
|
+
* same typo with three spellings, and all three are refused rather than
|
|
2526
|
+
* resolved — a refusal costs one re-run, and the alternative is evidence
|
|
2527
|
+
* nobody chose.
|
|
2528
|
+
* @param {string[]} args Command arguments.
|
|
2529
|
+
* @param {string} name Alias to read, including leading dashes.
|
|
2530
|
+
* @returns {string | undefined} The supplied value, or undefined when absent.
|
|
2531
|
+
*/
|
|
2532
|
+
function explicitPullRequestUrl(args, name) {
|
|
2533
|
+
const index = args.indexOf(name);
|
|
2534
|
+
if (index < 0) return undefined;
|
|
2535
|
+
const value = args[index + 1];
|
|
2536
|
+
if (value === undefined || value.startsWith("-") || value.trim() === "") {
|
|
2537
|
+
throw new TrackingError(
|
|
2538
|
+
`${name} was supplied without a pull-request URL. Pass ` +
|
|
2539
|
+
`${name} https://github.com/owner/repo/pull/123, or omit the flag ` +
|
|
2540
|
+
`entirely to fall back to LISA_PR_URL`
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
return value;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
/**
|
|
2547
|
+
* Resolve the canonical pull-request URL for backlink, validation and
|
|
2548
|
+
* completion, without letting an environment fallback outrank either alias.
|
|
2549
|
+
*
|
|
2550
|
+
* Two rules, and both exist because the backlink is how completion is PROVEN.
|
|
2513
2551
|
*
|
|
2514
|
-
*
|
|
2515
|
-
*
|
|
2516
|
-
*
|
|
2552
|
+
* Precedence: `LISA_PR_URL` is read only when neither explicit alias is
|
|
2553
|
+
* present. Resolving `--pr-url` with an environment fallback before inspecting
|
|
2554
|
+
* `--url` let the environment hide an explicit alias.
|
|
2555
|
+
*
|
|
2556
|
+
* Canonicalisation: the value returned is the parsed canonical URL, not the
|
|
2557
|
+
* caller's string. `assertBacklink` compares URLs by equality — token equality
|
|
2558
|
+
* in `textContainsBacklink`, `===` on both native link fields — and the
|
|
2559
|
+
* completion path already canonicalises through `mergedPullRequestEvidence`.
|
|
2560
|
+
* A backlink WRITTEN from the raw string therefore did not match the same pull
|
|
2561
|
+
* request READ back a moment later if the two spellings differed by a trailing
|
|
2562
|
+
* slash, and the failure surfaces as "no verified backlink" on work that is
|
|
2563
|
+
* genuinely linked. Canonicalising here puts one spelling on both sides. It
|
|
2564
|
+
* also means a value that is not a GitHub pull-request URL at all is refused
|
|
2565
|
+
* where it is supplied, rather than written into the managed comment and
|
|
2566
|
+
* discovered later by the check it was supposed to satisfy.
|
|
2567
|
+
*
|
|
2568
|
+
* Conflicting explicit aliases are refused on the RAW strings, so two
|
|
2569
|
+
* spellings of one pull request conflict just as two different pull requests
|
|
2570
|
+
* do. The looser rule would have to canonicalise first and then accept, and
|
|
2571
|
+
* accepting evidence the caller spelled two ways is the direction that reports
|
|
2572
|
+
* unproven work as proven.
|
|
2573
|
+
*
|
|
2574
|
+
* An empty `LISA_PR_URL` is absent, unlike an empty `--pr-url`: exporting an
|
|
2575
|
+
* empty variable is how a shell says "unset" and no operator typed it, so the
|
|
2576
|
+
* caller still gets the command's own "requires --pr-url <url>" refusal
|
|
2577
|
+
* instead of a parse error about a string nobody wrote.
|
|
2517
2578
|
* @param {string[]} args Command arguments.
|
|
2518
|
-
* @returns {string | undefined} The
|
|
2579
|
+
* @returns {string | undefined} The canonical URL, or undefined when none.
|
|
2519
2580
|
*/
|
|
2520
2581
|
function pullRequestUrlOption(args) {
|
|
2521
|
-
const
|
|
2522
|
-
|
|
2523
|
-
if (index < 0) return undefined;
|
|
2524
|
-
const value = args[index + 1];
|
|
2525
|
-
return value === undefined || value.startsWith("-") ? undefined : value;
|
|
2526
|
-
};
|
|
2527
|
-
const canonical = explicit("--pr-url");
|
|
2528
|
-
const alias = explicit("--url");
|
|
2582
|
+
const canonical = explicitPullRequestUrl(args, "--pr-url");
|
|
2583
|
+
const alias = explicitPullRequestUrl(args, "--url");
|
|
2529
2584
|
if (canonical && alias && canonical !== alias) {
|
|
2530
2585
|
throw new TrackingError(
|
|
2531
2586
|
"Conflicting pull-request evidence: --pr-url and --url name different values"
|
|
2532
2587
|
);
|
|
2533
2588
|
}
|
|
2534
|
-
|
|
2589
|
+
const supplied = canonical ?? alias;
|
|
2590
|
+
if (supplied !== undefined) return githubPullRequestUrl(supplied).url;
|
|
2591
|
+
const fromEnvironment = String(process.env.LISA_PR_URL ?? "").trim();
|
|
2592
|
+
if (fromEnvironment === "") return undefined;
|
|
2593
|
+
return githubPullRequestUrl(fromEnvironment).url;
|
|
2535
2594
|
}
|
|
2536
2595
|
|
|
2537
2596
|
function bind(args) {
|