@codyswann/lisa 4.35.0 → 4.35.2
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 +49 -26
- package/all/copy-overwrite/scripts/lib/kill-marks.mjs +1 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +49 -10
- package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +42 -4
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +33 -12
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +9 -4
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +9 -6
- package/dist/cli/apply.js +8 -8
- package/dist/cli/apply.js.map +1 -1
- package/dist/codex/scripts/_extract-edit-paths.sh +4 -0
- package/dist/core/apply-receipt.d.ts +17 -12
- package/dist/core/apply-receipt.d.ts.map +1 -1
- package/dist/core/apply-receipt.js +3 -3
- package/dist/core/apply-receipt.js.map +1 -1
- package/dist/core/config.d.ts +10 -6
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +20 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa.d.ts +34 -24
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +49 -41
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.js +10 -10
- package/dist/migrations/ensure-lisa-postinstall.d.ts +6 -37
- package/dist/migrations/ensure-lisa-postinstall.d.ts.map +1 -1
- package/dist/migrations/ensure-lisa-postinstall.js +20 -67
- package/dist/migrations/ensure-lisa-postinstall.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +33 -12
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-direct-issue-create.sh +49 -10
- package/plugins/lisa/hooks/block-managed-file-edits.sh +42 -4
- package/plugins/lisa/hooks/parity-safety-net.sh +33 -12
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +49 -10
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +33 -12
- 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-direct-issue-create.sh +49 -10
- package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +42 -4
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +33 -12
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +49 -10
- package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +42 -4
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +33 -12
- 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 +49 -10
- package/plugins/src/base/hooks/block-managed-file-edits.sh +42 -4
- package/plugins/src/base/hooks/parity-safety-net.sh +33 -12
|
@@ -1426,7 +1426,11 @@ EXEC_WRAPPERS = {
|
|
|
1426
1426
|
"nohup": (frozenset(), 0),
|
|
1427
1427
|
"setsid": (frozenset(), 0),
|
|
1428
1428
|
"stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
|
|
1429
|
-
"sudo": (frozenset({
|
|
1429
|
+
"sudo": (frozenset({
|
|
1430
|
+
"-u", "--user", "-g", "--group", "-C", "--close-from",
|
|
1431
|
+
"-p", "--prompt", "-h", "--host", "-r", "--role",
|
|
1432
|
+
"-t", "--type", "-U", "--other-user",
|
|
1433
|
+
}), 0),
|
|
1430
1434
|
"time": (frozenset(), 0),
|
|
1431
1435
|
"timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
|
|
1432
1436
|
}
|
|
@@ -1447,6 +1451,31 @@ NO_SCRIPT_OPTIONS = {
|
|
|
1447
1451
|
"ruby": {"-e"},
|
|
1448
1452
|
}
|
|
1449
1453
|
|
|
1454
|
+
# Interpreter switches whose value is a separate token, followed by the real
|
|
1455
|
+
# script operand. Treating the value as the script makes the guard inspect a
|
|
1456
|
+
# preload or option value and never reach what the interpreter executes.
|
|
1457
|
+
INTERPRETER_VALUE_OPTIONS = {
|
|
1458
|
+
"bash": {"-o", "--option"},
|
|
1459
|
+
"dash": {"-o"},
|
|
1460
|
+
"deno": {"-c", "--config"},
|
|
1461
|
+
"ksh": {"-o"},
|
|
1462
|
+
"node": {"-r", "--require"},
|
|
1463
|
+
"nodejs": {"-r", "--require"},
|
|
1464
|
+
"sh": {"-o"},
|
|
1465
|
+
"zsh": {"-o"},
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
# Runtime subcommands that introduce the script path rather than name it.
|
|
1469
|
+
INTERPRETER_SUBCOMMANDS = {
|
|
1470
|
+
"bun": {"run"},
|
|
1471
|
+
"deno": {"run"},
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def is_assignment_word(token):
|
|
1476
|
+
"""Whether token is a POSIX shell assignment in command-prefix position."""
|
|
1477
|
+
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*\+?=", token) is not None
|
|
1478
|
+
|
|
1450
1479
|
# Programs that READ their operands and never execute them as programs.
|
|
1451
1480
|
#
|
|
1452
1481
|
# This is an allowlist of readers rather than the inverse, and the asymmetry is
|
|
@@ -1501,7 +1530,7 @@ def executing_command(argv):
|
|
|
1501
1530
|
index = 0
|
|
1502
1531
|
while index < len(argv):
|
|
1503
1532
|
token = argv[index]
|
|
1504
|
-
if
|
|
1533
|
+
if is_assignment_word(token):
|
|
1505
1534
|
index += 1
|
|
1506
1535
|
continue
|
|
1507
1536
|
program = basename(token)
|
|
@@ -1568,16 +1597,20 @@ def executed_operand(argv):
|
|
|
1568
1597
|
# shebang — `./create.sh` names no interpreter and runs all the same.
|
|
1569
1598
|
if program not in EXECUTING_INTERPRETERS and resolve_operand(token) is not None:
|
|
1570
1599
|
return token
|
|
1571
|
-
# `bash < create.sh` runs that file as surely as `bash create.sh` does, and
|
|
1572
|
-
# `<` is not a segment boundary, so the pair survives in one argv.
|
|
1573
|
-
for index, argument in enumerate(args):
|
|
1574
|
-
if argument == "<" and index + 1 < len(args):
|
|
1575
|
-
return args[index + 1]
|
|
1576
1600
|
# Reached by a known interpreter AND by a program this parser cannot name.
|
|
1577
1601
|
# The unknown case is followed on purpose — see READ_ONLY_PROGRAMS.
|
|
1578
|
-
|
|
1579
|
-
|
|
1602
|
+
redirected = None
|
|
1603
|
+
index = 0
|
|
1604
|
+
while index < len(args):
|
|
1605
|
+
argument = args[index]
|
|
1606
|
+
if argument == "<":
|
|
1607
|
+
if index + 1 < len(args):
|
|
1608
|
+
redirected = args[index + 1]
|
|
1609
|
+
index += 2
|
|
1580
1610
|
continue
|
|
1611
|
+
if argument == "--":
|
|
1612
|
+
index += 1
|
|
1613
|
+
return args[index] if index < len(args) else redirected
|
|
1581
1614
|
if argument.startswith("-"):
|
|
1582
1615
|
# A command string or a module name; no script file follows.
|
|
1583
1616
|
head = argument.split("=", 1)[0]
|
|
@@ -1588,9 +1621,15 @@ def executed_operand(argv):
|
|
|
1588
1621
|
return None
|
|
1589
1622
|
elif head in NO_SCRIPT_OPTIONS.get(program, set()):
|
|
1590
1623
|
return None
|
|
1624
|
+
takes_value = head in INTERPRETER_VALUE_OPTIONS.get(program, set())
|
|
1625
|
+
index += 2 if takes_value and "=" not in argument else 1
|
|
1626
|
+
continue
|
|
1627
|
+
if argument in INTERPRETER_SUBCOMMANDS.get(program, set()):
|
|
1628
|
+
index += 1
|
|
1591
1629
|
continue
|
|
1592
1630
|
return argument
|
|
1593
|
-
|
|
1631
|
+
# `bash < create.sh` runs stdin only when no explicit script was named.
|
|
1632
|
+
return redirected
|
|
1594
1633
|
|
|
1595
1634
|
|
|
1596
1635
|
def file_operands(argv):
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
# from mine", and guessing "behind" is how a fork's stronger guard gets silently
|
|
91
91
|
# replaced by a weaker upstream one. Add a name here in the same commit that
|
|
92
92
|
# closes a vector.
|
|
93
|
-
# lisa-guard-capabilities: managed-path-resolution, lisaignore-precedence, generated-path-rebuild, redirect-target, tee-target, sed-in-place-all-spellings, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, direct-script-command-reach, analyzer-failure-visible
|
|
93
|
+
# lisa-guard-capabilities: managed-path-resolution, lisaignore-precedence, generated-path-rebuild, redirect-target, tee-target, sed-in-place-all-spellings, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, direct-script-command-reach, analyzer-failure-visible, apply-patch-parse-failure-visible, apply-patch-move-target, shell-command-string-reach
|
|
94
94
|
set -euo pipefail
|
|
95
95
|
|
|
96
96
|
input="$(cat)"
|
|
@@ -399,6 +399,7 @@ case "$tool_name" in
|
|
|
399
399
|
# *** Add File: <path>
|
|
400
400
|
# *** Update File: <path>
|
|
401
401
|
# *** Delete File: <path>
|
|
402
|
+
# *** Move to: <path>
|
|
402
403
|
#
|
|
403
404
|
# So every header is walked and each target classified; one managed
|
|
404
405
|
# target anywhere in a multi-file patch refuses the whole call, because
|
|
@@ -415,14 +416,25 @@ case "$tool_name" in
|
|
|
415
416
|
# legitimately cannot be one file. What is NOT restated is the
|
|
416
417
|
# classification: `managed_source` and `refuse` are the single copy here,
|
|
417
418
|
# exactly as for every other arm.
|
|
418
|
-
patch_text="$(printf '%s' "$input" | jq -
|
|
419
|
+
if ! patch_text="$(printf '%s' "$input" | jq -er '
|
|
420
|
+
if ((.tool_input.command? // "") | type) == "string"
|
|
421
|
+
then (.tool_input.command? // "")
|
|
422
|
+
else error("tool_input.command must be a string")
|
|
423
|
+
end')"; then
|
|
424
|
+
printf 'block-managed-file-edits: could not parse the apply_patch command; managed-file protection refused the edit\n' >&2
|
|
425
|
+
exit 2
|
|
426
|
+
fi
|
|
419
427
|
[ -n "$patch_text" ] || exit 0
|
|
420
428
|
while IFS= read -r patch_line; do
|
|
421
429
|
case "$patch_line" in
|
|
422
|
-
"*** Add File: "* | "*** Update File: "* | "*** Delete File: "*)
|
|
430
|
+
"*** Add File: "* | "*** Update File: "* | "*** Delete File: "*)
|
|
431
|
+
candidate="${patch_line#*File: }"
|
|
432
|
+
;;
|
|
433
|
+
"*** Move to: "*)
|
|
434
|
+
candidate="${patch_line#*** Move to: }"
|
|
435
|
+
;;
|
|
423
436
|
*) continue ;;
|
|
424
437
|
esac
|
|
425
|
-
candidate="${patch_line#*File: }"
|
|
426
438
|
[ -n "$candidate" ] || continue
|
|
427
439
|
if source_path="$(managed_source "$candidate")"; then
|
|
428
440
|
refuse "$candidate" "$source_path" "$(relative_path "$candidate")"
|
|
@@ -712,6 +724,29 @@ def shell_script_operand(args):
|
|
|
712
724
|
return None
|
|
713
725
|
|
|
714
726
|
|
|
727
|
+
def shell_command_string(statement):
|
|
728
|
+
"""The inline command a shell executes with `-c`, or None.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
statement: One statement's tokens.
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
The command string operand, or None when this is not a shell `-c` call.
|
|
735
|
+
"""
|
|
736
|
+
_token, program, args = command_word(statement)
|
|
737
|
+
if program not in SHELL_PROGRAMS:
|
|
738
|
+
return None
|
|
739
|
+
for index, token in enumerate(args):
|
|
740
|
+
if token == "--":
|
|
741
|
+
return None
|
|
742
|
+
if token.startswith("-") and not token.startswith("--") and "c" in token[1:]:
|
|
743
|
+
command_index = index + 1
|
|
744
|
+
if command_index < len(args) and args[command_index] == "--":
|
|
745
|
+
command_index += 1
|
|
746
|
+
return args[command_index] if command_index < len(args) else None
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
|
|
715
750
|
def write_targets(statement):
|
|
716
751
|
"""Paths this statement WRITES.
|
|
717
752
|
|
|
@@ -834,6 +869,9 @@ def collect(text, depth, seen, out):
|
|
|
834
869
|
if not statement:
|
|
835
870
|
continue
|
|
836
871
|
out.extend(write_targets(statement))
|
|
872
|
+
nested = shell_command_string(statement)
|
|
873
|
+
if nested is not None and depth < FOLLOW_MAX_DEPTH:
|
|
874
|
+
collect(nested, depth + 1, seen, out)
|
|
837
875
|
operand = executed_script(statement)
|
|
838
876
|
if operand is None:
|
|
839
877
|
continue
|
|
@@ -179,12 +179,14 @@ EOF
|
|
|
179
179
|
# The patch file is per-worktree: `mktemp` names it, so nothing has to invent a
|
|
180
180
|
# filename, and it lives in this agent's own TMPDIR where no sibling can reach
|
|
181
181
|
# it. Untracked files are unaffected by both refused operations, so `git diff`
|
|
182
|
-
# captures everything at risk.
|
|
182
|
+
# captures everything at risk. `HEAD` includes staged and unstaged changes, and
|
|
183
|
+
# `--binary` keeps binary edits recoverable rather than emitting a text-only
|
|
184
|
+
# placeholder that cannot be applied.
|
|
183
185
|
PRESERVE_GUIDANCE="$(
|
|
184
186
|
cat <<'EOF'
|
|
185
187
|
Preserve the work first, per-worktree:
|
|
186
188
|
|
|
187
|
-
git diff [-- <path>] > "$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX.patch")"
|
|
189
|
+
git diff --binary HEAD [-- <path>] > "$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX.patch")"
|
|
188
190
|
|
|
189
191
|
and restore it later with `git apply <that file>`.
|
|
190
192
|
|
|
@@ -444,6 +446,7 @@ follow_refuse() {
|
|
|
444
446
|
# so peak process count never rises. The latency was the whole problem.
|
|
445
447
|
follow_unwrapped=""
|
|
446
448
|
follow_operand_index=0
|
|
449
|
+
follow_located=""
|
|
447
450
|
|
|
448
451
|
# Strip substitution, quote and alias wrappers off one token. A sibling of
|
|
449
452
|
# strip_subst_wrappers() below rather than a call to it: that one is defined
|
|
@@ -568,6 +571,7 @@ follow_add_base() {
|
|
|
568
571
|
# this hook cannot evaluate.
|
|
569
572
|
follow_locate() {
|
|
570
573
|
local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
|
|
574
|
+
follow_located=""
|
|
571
575
|
tmp="${tmp%/}"
|
|
572
576
|
[ -n "$tmp" ] || tmp="/tmp"
|
|
573
577
|
# The tilde arms below are LITERAL text: this is the unexpanded spelling the
|
|
@@ -589,14 +593,14 @@ follow_locate() {
|
|
|
589
593
|
"" | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*) return 1 ;;
|
|
590
594
|
/*)
|
|
591
595
|
[ -f "$token" ] && [ -r "$token" ] || return 1
|
|
592
|
-
|
|
596
|
+
follow_located="$token"
|
|
593
597
|
return 0
|
|
594
598
|
;;
|
|
595
599
|
esac
|
|
596
600
|
while IFS= read -r base; do
|
|
597
601
|
[ -n "$base" ] || continue
|
|
598
602
|
if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
|
|
599
|
-
|
|
603
|
+
follow_located="$base/$token"
|
|
600
604
|
return 0
|
|
601
605
|
fi
|
|
602
606
|
done <<<"$PWD"$'\n'"$proj$follow_bases"
|
|
@@ -641,12 +645,13 @@ follow_take() {
|
|
|
641
645
|
follow_target() {
|
|
642
646
|
local token="$1" depth="$2" path=""
|
|
643
647
|
[ -n "$token" ] || return 0
|
|
644
|
-
if !
|
|
648
|
+
if ! follow_locate "$token"; then
|
|
645
649
|
if [ "$depth" -eq 0 ]; then
|
|
646
650
|
follow_refuse "$token" "no readable file could be resolved for it"
|
|
647
651
|
fi
|
|
648
652
|
return 0
|
|
649
653
|
fi
|
|
654
|
+
path="$follow_located"
|
|
650
655
|
follow_take "$path" "$depth"
|
|
651
656
|
}
|
|
652
657
|
|
|
@@ -667,11 +672,18 @@ follow_target() {
|
|
|
667
672
|
# Accepted residual: a shebang script whose relative path resolves only from a
|
|
668
673
|
# working directory this hook does not share is not followed.
|
|
669
674
|
follow_direct() {
|
|
670
|
-
local token="$1" depth="$2" path="" first=""
|
|
671
|
-
|
|
675
|
+
local token="$1" depth="$2" path="" first="" raw="" char="" k=0
|
|
676
|
+
local LC_ALL=C
|
|
677
|
+
if ! follow_locate "$token"; then
|
|
672
678
|
return 0
|
|
673
679
|
fi
|
|
674
|
-
|
|
680
|
+
path="$follow_located"
|
|
681
|
+
IFS= read -r -n 256 raw <"$path" 2>/dev/null || true
|
|
682
|
+
while [ "$k" -lt "${#raw}" ]; do
|
|
683
|
+
char="${raw:k:1}"
|
|
684
|
+
case "$char" in [[:print:]]) first="$first$char" ;; esac
|
|
685
|
+
k=$((k + 1))
|
|
686
|
+
done
|
|
675
687
|
case "$first" in
|
|
676
688
|
'#!'*[/[:space:]]bash* | '#!'*[/[:space:]]sh* | '#!'*[/[:space:]]zsh* \
|
|
677
689
|
| '#!'*[/[:space:]]ksh* | '#!'*[/[:space:]]dash* | '#!'*[/[:space:]]ash*) ;;
|
|
@@ -701,7 +713,7 @@ follow_next_operand() {
|
|
|
701
713
|
follow_scan() {
|
|
702
714
|
local text="$1" depth="$2"
|
|
703
715
|
local split_out line sep stmt t tj i j n cmd_pos eval_mode inline arg_exec
|
|
704
|
-
local heredoc redirected pipe_cat prev_cat pending_operand
|
|
716
|
+
local heredoc redirected pipe_cat prev_cat pending_operand stdin_mode
|
|
705
717
|
local find_mode dispatch_pending
|
|
706
718
|
local -a toks=()
|
|
707
719
|
split_out="$(follow_split "$text")"
|
|
@@ -888,6 +900,7 @@ follow_scan() {
|
|
|
888
900
|
inline=0
|
|
889
901
|
redirected=""
|
|
890
902
|
heredoc=0
|
|
903
|
+
stdin_mode=0
|
|
891
904
|
j=$((i + 1))
|
|
892
905
|
while [ "$j" -lt "$n" ]; do
|
|
893
906
|
follow_unwrap "${toks[j]}"
|
|
@@ -920,6 +933,7 @@ follow_scan() {
|
|
|
920
933
|
;;
|
|
921
934
|
--*) j=$((j + 1)) ;;
|
|
922
935
|
-*c*) inline=1; j=$((j + 1)) ;;
|
|
936
|
+
-s | -[a-zA-Z]*s[a-zA-Z]*) stdin_mode=1; j=$((j + 1)) ;;
|
|
923
937
|
-*) j=$((j + 1)) ;;
|
|
924
938
|
*) break ;;
|
|
925
939
|
esac
|
|
@@ -944,6 +958,14 @@ follow_scan() {
|
|
|
944
958
|
i="$j"
|
|
945
959
|
continue
|
|
946
960
|
fi
|
|
961
|
+
if [ "$stdin_mode" -eq 1 ]; then
|
|
962
|
+
# `sh -s -- -y` reads its program from stdin; tokens after `--` are
|
|
963
|
+
# positional arguments to that program, never a script path.
|
|
964
|
+
[ -z "$pipe_cat" ] || follow_target "$pipe_cat" "$depth"
|
|
965
|
+
cmd_pos=0
|
|
966
|
+
i="$n"
|
|
967
|
+
continue
|
|
968
|
+
fi
|
|
947
969
|
if [ "$j" -ge "$n" ]; then
|
|
948
970
|
# A dispatcher supplies the script from ANOTHER command's output, so
|
|
949
971
|
# the operand exists but is unreadable here. That is the fail-closed
|
|
@@ -1806,15 +1828,14 @@ readonly SQL_TRUNCATE_EVIDENCE='\btruncate[[:space:]]+(table[[:space:]]+[[:alnum
|
|
|
1806
1828
|
# the same command must also invoke a command-line database client. Prose in a
|
|
1807
1829
|
# tracker filing or a commit message invokes none, which is exactly the
|
|
1808
1830
|
# difference the text scan could not see on its own.
|
|
1809
|
-
readonly
|
|
1810
|
-
readonly SQL_TRUNCATE_BARE='\btruncate[[:space:]]+(only[[:space:]]+)?[[:alnum:]_."`]'
|
|
1831
|
+
readonly SQL_CLIENT_TRUNCATE='(^|[;&|()][[:space:]]*)([[:alnum:]_./-]*/)?(psql|pgcli|mysql|mysqladmin|mysqlsh|mariadb|mycli|sqlite3|sqlcmd|cqlsh|clickhouse-client|cockroach|mongosh|duckdb|usql)([[:space:]]+[^;&|()]*)?[[:space:]]+(-e|-c|--command|--eval)(=|[[:space:]]+)[^;&|()]*\btruncate[[:space:]]+(only[[:space:]]+)?[[:alnum:]_."`]'
|
|
1811
1832
|
# Residual, accepted and stated: a bare `TRUNCATE <name>` with no terminator,
|
|
1812
1833
|
# no qualification and no recognized client in the same command goes unmatched
|
|
1813
1834
|
# — the same trade the `drop` arm has always made for `drop the ball`. No DROP
|
|
1814
1835
|
# shape is weakened, and the guard still fails in the safe direction.
|
|
1815
1836
|
if matches "$SQL_DROP" \
|
|
1816
1837
|
|| matches "$SQL_TRUNCATE_EVIDENCE" \
|
|
1817
|
-
||
|
|
1838
|
+
|| matches "$SQL_CLIENT_TRUNCATE"; then
|
|
1818
1839
|
block "destructive SQL (DROP/TRUNCATE) detected"
|
|
1819
1840
|
fi
|
|
1820
1841
|
|