@codyswann/lisa 4.73.0 → 4.73.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.
Files changed (105) hide show
  1. package/all/copy-overwrite/scripts/lib/history-secret-git.mjs +158 -5
  2. package/all/copy-overwrite/scripts/lib/npm-update-bun.mjs +6 -2
  3. package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +25 -1
  4. package/all/copy-overwrite/scripts/lisa-history-secrets.mjs +17 -3
  5. package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +86 -7
  6. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +7 -1
  7. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +62 -7
  8. package/all/copy-overwrite/scripts/lisa-rails-prepush.mjs +4 -1
  9. package/all/copy-overwrite/scripts/lisa-work-item.mjs +191 -5
  10. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +6 -6
  11. package/all/deletions.json +19 -2
  12. package/dist/codex/scripts/block-no-verify.sh +62 -7
  13. package/dist/core/config.d.ts +16 -0
  14. package/dist/core/config.d.ts.map +1 -1
  15. package/dist/core/config.js.map +1 -1
  16. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  17. package/dist/core/lisa-owned-hash-ledger.js +82 -0
  18. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  19. package/dist/core/lisa.d.ts +31 -0
  20. package/dist/core/lisa.d.ts.map +1 -1
  21. package/dist/core/lisa.js +69 -4
  22. package/dist/core/lisa.js.map +1 -1
  23. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  24. package/dist/core/retire-unmodified.d.ts +66 -0
  25. package/dist/core/retire-unmodified.d.ts.map +1 -0
  26. package/dist/core/retire-unmodified.js +244 -0
  27. package/dist/core/retire-unmodified.js.map +1 -0
  28. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  29. package/dist/core/upstream-evidence-manifest.js +23 -17
  30. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  31. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +7 -1
  32. package/dist/opencode/plugin-templates/block-no-verify.sh +62 -7
  33. package/package.json +4 -4
  34. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa/hooks/auto-update.mjs +69 -1
  37. package/plugins/lisa/hooks/block-blind-automerge.sh +86 -7
  38. package/plugins/lisa/hooks/block-managed-file-edits.sh +7 -1
  39. package/plugins/lisa/hooks/block-no-verify.agy.sh +62 -7
  40. package/plugins/lisa/hooks/block-no-verify.sh +62 -7
  41. package/plugins/lisa-agy/hooks/block-blind-automerge.sh +86 -7
  42. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +7 -1
  43. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +62 -7
  44. package/plugins/lisa-agy/plugin.json +1 -1
  45. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  48. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-copilot/hooks/auto-update.mjs +69 -1
  52. package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +86 -7
  53. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +7 -1
  54. package/plugins/lisa-copilot/hooks/block-no-verify.sh +62 -7
  55. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cursor/hooks/auto-update.mjs +69 -1
  57. package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +86 -7
  58. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +7 -1
  59. package/plugins/lisa-cursor/hooks/block-no-verify.sh +62 -7
  60. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-expo-agy/plugin.json +1 -1
  63. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  68. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  73. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  78. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  83. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-rails-agy/plugin.json +1 -1
  88. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  93. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  98. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/src/base/hooks/auto-update.mjs +69 -1
  101. package/plugins/src/base/hooks/block-blind-automerge.sh +86 -7
  102. package/plugins/src/base/hooks/block-managed-file-edits.sh +7 -1
  103. package/plugins/src/base/hooks/block-no-verify.agy.sh +62 -7
  104. package/plugins/src/base/hooks/block-no-verify.sh +62 -7
  105. package/scripts/check-shipped-surface-removals.mjs +22 -2
@@ -44,12 +44,15 @@ export const gitEnvironment = () => {
44
44
  };
45
45
  };
46
46
 
47
+ /** Replacement refs must never redirect which objects a proof reads. */
48
+ const NO_REPLACE_OBJECTS = "--no-replace-objects";
49
+
47
50
  /** Git errors can contain arbitrary payloads; only authored guidance leaves here. */
48
- export const gitRead = (args, cwd) => {
51
+ export const gitRead = (args, cwd, input) => {
49
52
  const result = spawnSync(
50
53
  "git",
51
54
  [
52
- "--no-replace-objects",
55
+ NO_REPLACE_OBJECTS,
53
56
  "-c",
54
57
  "diff.external=",
55
58
  "-c",
@@ -60,6 +63,7 @@ export const gitRead = (args, cwd) => {
60
63
  cwd,
61
64
  env: gitEnvironment(),
62
65
  encoding: "utf8",
66
+ input,
63
67
  timeout: 120000,
64
68
  maxBuffer: 64 * 1024 * 1024,
65
69
  }
@@ -151,8 +155,143 @@ export const eventPairs = (event, name, width) => {
151
155
  );
152
156
  };
153
157
 
158
+ /**
159
+ * Git configuration exactly as the push itself sees it.
160
+ *
161
+ * gitEnvironment pins configuration so history reads cannot be steered, which
162
+ * is right for walking objects and wrong for naming a destination: the push
163
+ * resolved its URL, `pushurl`, `pushInsteadOf` and credentials through user,
164
+ * system and environment configuration, and a destination resolved under any
165
+ * other configuration may be a different repository.
166
+ */
167
+ const remoteEnvironment = () => {
168
+ const environment = gitEnvironment();
169
+ for (const key of Object.keys(environment))
170
+ if (key.startsWith("GIT_CONFIG")) delete environment[key];
171
+ for (const [key, value] of Object.entries(process.env))
172
+ if (key.startsWith("GIT_CONFIG") && value !== undefined)
173
+ environment[key] = value;
174
+ // Never wait on a person: no terminal prompt and no askpass program, so a
175
+ // destination that needs an unavailable credential fails at once.
176
+ return { ...environment, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "" };
177
+ };
178
+
179
+ /**
180
+ * Run one destination-resolution read under the push's own configuration.
181
+ * @param {string[]} args Git arguments.
182
+ * @param {string} cwd Repository directory.
183
+ * @returns {string | null} Trimmed stdout, or null when Git refused or failed.
184
+ */
185
+ const remoteRead = (args, cwd) => {
186
+ const result = spawnSync("git", [NO_REPLACE_OBJECTS, ...args], {
187
+ cwd,
188
+ env: remoteEnvironment(),
189
+ encoding: "utf8",
190
+ timeout: 60000,
191
+ maxBuffer: 64 * 1024 * 1024,
192
+ stdio: ["ignore", "pipe", "ignore"],
193
+ });
194
+ // probe-direction: fail-closed — every caller treats null as "the
195
+ // destination is unproved", which subtracts nothing from the scan.
196
+ if (result.error || result.signal || result.status !== 0) return null;
197
+ return result.stdout.trim();
198
+ };
199
+
200
+ /**
201
+ * The repository a push is actually going to, from Git's pre-push arguments.
202
+ *
203
+ * Git calls the hook with the remote's name (or the URL, when the push named
204
+ * no remote) and the URL it pushes to, already rewritten by `pushInsteadOf`.
205
+ * That URL is the destination, so it wins when present. A wrapper that
206
+ * forwards only a remote NAME gets that remote's push URL, resolved under the
207
+ * push's own configuration; a remote with several push URLs, or a bare URL
208
+ * with no `$2`, cannot be pinned to one destination and returns null. A value
209
+ * that looks like an option is never used.
210
+ * @param {string[]} remoteArgs Git's pre-push `$1` and `$2`, as forwarded.
211
+ * @param {string} cwd Repository directory.
212
+ * @returns {string | null} The destination, or null when it cannot be proved.
213
+ */
214
+ export const pushDestination = (remoteArgs, cwd) => {
215
+ const [remote, url] = remoteArgs ?? [];
216
+ const usable = value =>
217
+ typeof value === "string" && value !== "" && !value.startsWith("-");
218
+ if (usable(url)) return url;
219
+ if (!usable(remote)) return null;
220
+ const names = (remoteRead(["remote"], cwd) ?? "").split("\n");
221
+ if (!names.includes(remote)) return null;
222
+ const urls = (
223
+ remoteRead(["remote", "get-url", "--push", "--all", "--", remote], cwd) ??
224
+ ""
225
+ )
226
+ .split("\n")
227
+ .filter(Boolean);
228
+ return urls.length === 1 ? urls[0] : null;
229
+ };
230
+
231
+ /**
232
+ * Commit IDs the push destination advertises right now that exist locally.
233
+ *
234
+ * Asked of the destination itself (`git ls-remote`), not read from local
235
+ * tracking refs: a tracking ref can outlive a branch deleted or reset on the
236
+ * remote, and with a separate pushurl it describes a different repository, so
237
+ * either would exclude commits the destination lacks. An advertised ID this
238
+ * repository does not have cannot bound a local walk and is dropped, which
239
+ * only ever widens the scan.
240
+ * @param {string | null} destination The push destination from pushDestination.
241
+ * @param {string} cwd Repository directory.
242
+ * @returns {string[]} IDs to exclude; empty when nothing could be proved.
243
+ */
244
+ export const advertisedTips = (destination, cwd) => {
245
+ if (destination === null) return [];
246
+ // An `insteadOf` rule can rewrite even a URL Git already rewrote for the
247
+ // push, and then the probe would describe another repository. Trust the
248
+ // advertisement only when ls-remote would contact the destination itself.
249
+ // probe-direction: fail-closed — a URL that cannot be confirmed subtracts
250
+ // nothing.
251
+ if (
252
+ remoteRead(["ls-remote", "--get-url", "--", destination], cwd) !==
253
+ destination
254
+ )
255
+ return [];
256
+ const listed = spawnSync(
257
+ "git",
258
+ [NO_REPLACE_OBJECTS, "ls-remote", "--", destination],
259
+ {
260
+ cwd,
261
+ env: remoteEnvironment(),
262
+ encoding: "utf8",
263
+ timeout: 60000,
264
+ maxBuffer: 64 * 1024 * 1024,
265
+ stdio: ["ignore", "pipe", "ignore"],
266
+ }
267
+ );
268
+ // probe-direction: fail-closed — a destination that cannot be asked
269
+ // advertises nothing, so nothing is subtracted and the complete history is
270
+ // scanned.
271
+ if (listed.error || listed.signal || listed.status !== 0) return [];
272
+ const advertised = [
273
+ ...new Set(
274
+ listed.stdout
275
+ .split("\n")
276
+ .map(line => line.split("\t")[0])
277
+ .filter(id => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(id))
278
+ ),
279
+ ];
280
+ if (advertised.length === 0) return [];
281
+ const present = gitRead(
282
+ ["cat-file", "--batch-check=%(objectname) %(objecttype)"],
283
+ cwd,
284
+ advertised.map(id => `${id}\n`).join("")
285
+ );
286
+ return present
287
+ .split("\n")
288
+ .map(line => line.split(" "))
289
+ .filter(([, type]) => type === "commit" || type === "tag")
290
+ .map(([id]) => id);
291
+ };
292
+
154
293
  /** Pairwise subtraction prevents one ref's old tip from hiding another update. */
155
- export const introducedCommits = (pairs, cwd, width) => {
294
+ export const introducedCommits = (pairs, cwd, width, remoteArgs = []) => {
156
295
  const oid = new RegExp(`^[a-f0-9]{${width}}$`, "u");
157
296
  for (const pair of pairs) {
158
297
  if (!oid.test(pair.before ?? "") || !oid.test(pair.after ?? ""))
@@ -175,6 +314,16 @@ export const introducedCommits = (pairs, cwd, width) => {
175
314
  "Legacy Git grafts prevent complete history proof. Use an unmodified complete repository checkout."
176
315
  );
177
316
  const commits = new Set();
317
+ // A new remote ref (zero `before`) has no old tip to subtract, and scanning
318
+ // everything reachable from it counts the remote's own published history as
319
+ // introduced: every new branch of an old repository then re-litigates its
320
+ // root commit. What a push introduces is what the destination does not
321
+ // already hold, so a new ref subtracts what the push destination advertises.
322
+ // Without a destination, or one that cannot be asked, nothing is subtracted
323
+ // and the complete reachable history is scanned as before.
324
+ const published = updates.some(pair => /^0+$/u.test(pair.before))
325
+ ? advertisedTips(pushDestination(remoteArgs, cwd), cwd)
326
+ : [];
178
327
  for (const { before, after } of updates) {
179
328
  const next = gitRead(["rev-parse", "--verify", `${after}^{commit}`], cwd);
180
329
  const previous = /^0+$/u.test(before)
@@ -190,9 +339,13 @@ export const introducedCommits = (pairs, cwd, width) => {
190
339
  "Required Git trees or blobs are missing. Fetch complete objects, repair the checkout and retry."
191
340
  );
192
341
  }
342
+ // Exclusions go through stdin: a remote can hold more refs than one
343
+ // command line carries.
344
+ const exclusions = previous ? [previous] : published;
193
345
  const list = gitRead(
194
- ["rev-list", next, ...(previous ? [`^${previous}`] : [])],
195
- cwd
346
+ ["rev-list", "--stdin", next],
347
+ cwd,
348
+ exclusions.map(oid => `^${oid}\n`).join("")
196
349
  );
197
350
  for (const commit of list.split("\n").filter(Boolean)) commits.add(commit);
198
351
  }
@@ -112,7 +112,10 @@ export async function qualifiedBun(env, { executable, deadline } = {}) {
112
112
  return invoke;
113
113
  }
114
114
 
115
- /** Native JSONC parsing is data-only; every Bun package must match a public npm lock identity. */
115
+ /**
116
+ * Native JSONC parsing is data-only; every Bun package must match a public npm lock identity.
117
+ * npm aliases record their real registry name separately from the installation path.
118
+ */
116
119
  export async function verifyBunLock(bun, app, options = {}) {
117
120
  const text = readBytes(join(app, "bun.lock"), 1_048_576, false);
118
121
  const parsed = await bun(
@@ -156,7 +159,8 @@ export async function verifyBunLock(bun, app, options = {}) {
156
159
  );
157
160
  const matching = nodes.some(
158
161
  ([path, node]) =>
159
- row[0] === `${path.split("node_modules/").at(-1)}@${node.version}` &&
162
+ row[0] ===
163
+ `${node.name ?? path.split("node_modules/").at(-1)}@${node.version}` &&
160
164
  row[3] === node.integrity &&
161
165
  (row[1] === "" || row[1] === node.resolved)
162
166
  );
@@ -109,7 +109,31 @@ export function validateLock(lock, manifest, updates) {
109
109
  !item.link,
110
110
  "unsupported lock node"
111
111
  );
112
- const url = new URL(item.resolved);
112
+ // A bundled dependency ships inside its parent's tarball, so npm records
113
+ // no source or integrity of its own: the parent's registry integrity is
114
+ // what covers its bytes. Accept it only nested under a parent this loop
115
+ // also validates, and never when it claims a source of its own.
116
+ if (item.inBundle === true) {
117
+ required(
118
+ item.resolved === undefined && item.integrity === undefined,
119
+ "bundled lock node declares its own source"
120
+ );
121
+ const boundary = name.lastIndexOf("/node_modules/");
122
+ const parent = boundary < 0 ? "" : name.slice(0, boundary);
123
+ required(
124
+ parent.startsWith("node_modules/") &&
125
+ Object.hasOwn(lock.packages, parent),
126
+ "bundled lock node has no registry parent"
127
+ );
128
+ continue;
129
+ }
130
+ required(typeof item.resolved === "string", "registry source missing");
131
+ let url;
132
+ try {
133
+ url = new URL(item.resolved);
134
+ } catch {
135
+ required(false, "registry source unparseable");
136
+ }
113
137
  required(
114
138
  url.protocol === "https:" &&
115
139
  url.hostname === "registry.npmjs.org" &&
@@ -41,9 +41,23 @@ export const main = async (
41
41
  const scannerIndex = args.indexOf("--scanner");
42
42
  const scanner = scannerIndex < 0 ? undefined : args[scannerIndex + 1];
43
43
  const mode = args[0];
44
+ // Git hands a pre-push hook the remote's name (or URL) and URL as $1/$2.
45
+ // Both are optional so an older wrapper that forwards neither keeps the
46
+ // complete-history scan; neither may look like an option.
47
+ const remoteArgs =
48
+ mode === "pre-push"
49
+ ? args.slice(1, scannerIndex < 0 ? undefined : scannerIndex)
50
+ : [];
44
51
  const accepted =
45
52
  mode === "pre-push"
46
- ? ["pre-push", ...(scanner ? ["--scanner", scanner] : [])]
53
+ ? [
54
+ "pre-push",
55
+ ...(remoteArgs.length <= 2 &&
56
+ remoteArgs.every(value => value !== "" && !value.startsWith("-"))
57
+ ? remoteArgs
58
+ : []),
59
+ ...(scanner ? ["--scanner", scanner] : []),
60
+ ]
47
61
  : [
48
62
  "ci",
49
63
  ...(args[1] === "--event"
@@ -56,7 +70,7 @@ export const main = async (
56
70
  !["pre-push", "ci"].includes(mode)
57
71
  )
58
72
  throw new HistorySecretError(
59
- "Invalid scanner command. Use pre-push with actual stdin or ci --event <event-json> --event-name <push|pull_request>; optional --scanner <pinned-executable>."
73
+ "Invalid scanner command. Use pre-push [<remote> [<url>]] with actual stdin or ci --event <event-json> --event-name <push|pull_request>; optional --scanner <pinned-executable>."
60
74
  );
61
75
  const width = objectWidth(cwd);
62
76
  let pairs;
@@ -87,7 +101,7 @@ export const main = async (
87
101
  width
88
102
  );
89
103
  }
90
- const commits = introducedCommits(pairs, cwd, width);
104
+ const commits = introducedCommits(pairs, cwd, width, remoteArgs);
91
105
  if (commits.length === 0) {
92
106
  console.log("No introduced history. Scanner was not run.");
93
107
  return 0;
@@ -211,6 +211,7 @@ if [ -z "$command_str" ]; then
211
211
  fi
212
212
 
213
213
  if ! BLOCK_BLIND_AUTOMERGE_COMMAND="$command_str" python3 - <<'PY'
214
+ import io
214
215
  import json
215
216
  import os
216
217
  import re
@@ -244,7 +245,41 @@ MERGE_SEPARATE_VALUE = {
244
245
 
245
246
  COMMAND_SEPARATORS = {
246
247
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
248
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
249
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
250
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
251
+ # whose arguments happened to include git. The case terminators end a
252
+ # command the same way.
253
+ "|&", ";;", ";&", ";;&",
247
254
  }
255
+ # shlex also GLUES adjacent punctuation into one token across distinct
256
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
257
+ # test can recognise. An operator-only token is therefore re-split into the
258
+ # operators it is made of, longest first. Redirections are kept whole and are
259
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
260
+ # commit's argv scope.
261
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
262
+ SHELL_OPERATOR = re.compile("|".join(
263
+ re.escape(operator) for operator in sorted(
264
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
265
+ key=lambda operator: (-len(operator), operator),
266
+ )
267
+ ))
268
+
269
+
270
+ def split_operators(token):
271
+ """Split a glued operator-only token into the shell operators it holds.
272
+
273
+ Args:
274
+ token: One shlex token that was not read inside a quote or escape.
275
+
276
+ Returns:
277
+ The operators, longest first, or the token itself when it is not
278
+ made only of shell punctuation.
279
+ """
280
+ if OPERATOR_ONLY.match(token):
281
+ return SHELL_OPERATOR.findall(token)
282
+ return [token]
248
283
 
249
284
  # Shell wrappers whose `-c` argument is a command in a string rather than argv.
250
285
  # The outer argv names bash, so the arming is invisible without recursing once.
@@ -422,12 +457,40 @@ def shell_tokens(text):
422
457
  Returns:
423
458
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
424
459
  """
425
- lexer = shlex.shlex(
426
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
427
- )
460
+ stream = OperatorStream(line_boundaries_as_separators(text))
461
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
428
462
  lexer.whitespace_split = True
429
463
  lexer.commenters = ""
430
- return list(lexer)
464
+ stream.lexer = lexer
465
+ tokens = []
466
+ while True:
467
+ stream.literal_operator = False
468
+ token = lexer.get_token()
469
+ if token is None:
470
+ return tokens
471
+ # Quoted punctuation is an argument, never a boundary: a branch can be
472
+ # named `|&`, and `gh pr merge '|&' --auto` must keep `--auto` in the
473
+ # merge's own argv. Only real operator runs are split.
474
+ if stream.literal_operator:
475
+ tokens.append(
476
+ chr(0) + token if token in COMMAND_SEPARATORS else token
477
+ )
478
+ else:
479
+ tokens.extend(split_operators(token))
480
+
481
+
482
+ class OperatorStream(io.StringIO):
483
+ """Remember punctuation read while shlex is inside a quote or escape."""
484
+
485
+ lexer = None
486
+ literal_operator = False
487
+
488
+ def read(self, size=-1):
489
+ value = super().read(size)
490
+ if (self.lexer is not None and self.lexer.state in {"'", '"', chr(92)}
491
+ and any(char in "();|&<>" for char in value)):
492
+ self.literal_operator = True
493
+ return value
431
494
 
432
495
 
433
496
  def is_gh(token):
@@ -493,6 +556,22 @@ def arms_auto_merge(argv):
493
556
  return False
494
557
 
495
558
 
559
+ def literal_value(token):
560
+ """The argument a token stands for, without the quoted-separator marker.
561
+
562
+ `shell_tokens` prefixes a QUOTED separator with NUL so no scan stops at it.
563
+ That marker is for boundary checks only: a value handed to a gh probe
564
+ (a selector, a base ref, a repo) must be the argument the shell passes.
565
+
566
+ Args:
567
+ token: One token from `shell_tokens`.
568
+
569
+ Returns:
570
+ The token as the shell would pass it.
571
+ """
572
+ return token[1:] if token.startswith(chr(0)) else token
573
+
574
+
496
575
  def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
497
576
  """The PR the command names, as gh itself would read it.
498
577
 
@@ -518,7 +597,7 @@ def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
518
597
  index += 1
519
598
  continue
520
599
  if not token.startswith("-"):
521
- return token
600
+ return literal_value(token)
522
601
  return None
523
602
 
524
603
 
@@ -538,7 +617,7 @@ def retarget_target(argv):
538
617
  if token in COMMAND_SEPARATORS:
539
618
  return None
540
619
  if token in BASE_FLAGS and index < len(argv):
541
- return argv[index]
620
+ return literal_value(argv[index])
542
621
  if token.startswith("--base="):
543
622
  return token.split("=", 1)[1]
544
623
  if token in EDIT_SEPARATE_VALUE:
@@ -562,7 +641,7 @@ def repo_flag(tokens, start):
562
641
  if token in COMMAND_SEPARATORS:
563
642
  break
564
643
  if token in {"-R", "--repo"} and index + 1 < len(tokens):
565
- return ["--repo", tokens[index + 1]]
644
+ return ["--repo", literal_value(tokens[index + 1])]
566
645
  if token.startswith("--repo="):
567
646
  return ["--repo", token.split("=", 1)[1]]
568
647
  index += 1
@@ -597,7 +597,13 @@ ENV_SPLIT_UNRESOLVED = 42
597
597
  BACKSLASH = chr(92)
598
598
  NEWLINE = chr(10)
599
599
 
600
- STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
600
+ # `|&` pipes stdout and stderr into the next command, and the case terminators
601
+ # end a statement as `;` does. Without them `printf x |& tee <managed>` stayed
602
+ # one statement whose command word was `printf`, so the `tee` write target was
603
+ # never examined.
604
+ STATEMENT_SEPARATORS = {
605
+ ";", "|", "||", "&", "&&", "(", ")", "&|", "|&", ";;", ";&", ";;&",
606
+ }
601
607
  # Ordinary output, noclobber override, and combined stdout/stderr output.
602
608
  # shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
603
609
  REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
@@ -271,7 +271,41 @@ def disables_verification(token):
271
271
  # tokens, so the invocation's boundary is a token the scan can stop at.
272
272
  COMMAND_SEPARATORS = {
273
273
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
274
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
275
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
276
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
277
+ # whose arguments happened to include git. The case terminators end a
278
+ # command the same way.
279
+ "|&", ";;", ";&", ";;&",
274
280
  }
281
+ # shlex also GLUES adjacent punctuation into one token across distinct
282
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
283
+ # test can recognise. An operator-only token is therefore re-split into the
284
+ # operators it is made of, longest first. Redirections are kept whole and are
285
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
286
+ # commit's argv scope.
287
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
288
+ SHELL_OPERATOR = re.compile("|".join(
289
+ re.escape(operator) for operator in sorted(
290
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
291
+ key=lambda operator: (-len(operator), operator),
292
+ )
293
+ ))
294
+
295
+
296
+ def split_operators(token):
297
+ """Split a glued operator-only token into the shell operators it holds.
298
+
299
+ Args:
300
+ token: One shlex token that was not read inside a quote or escape.
301
+
302
+ Returns:
303
+ The operators, longest first, or the token itself when it is not
304
+ made only of shell punctuation.
305
+ """
306
+ if OPERATOR_ONLY.match(token):
307
+ return SHELL_OPERATOR.findall(token)
308
+ return [token]
275
309
 
276
310
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
277
311
  MAX_SHELL_NESTING = 8
@@ -425,14 +459,28 @@ def shell_tokens(text):
425
459
  Returns:
426
460
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
427
461
  """
428
- lexer = shlex.shlex(
429
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
430
- )
462
+ stream = OperatorStream(line_boundaries_as_separators(text))
463
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
431
464
  lexer.whitespace_split = True
432
465
  # shlex treats `#` as a comment introducer by default and would silently
433
466
  # truncate the rest of the line; `shlex.split` disables it, and so must this.
434
467
  lexer.commenters = ""
435
- return list(lexer)
468
+ stream.lexer = lexer
469
+ tokens = []
470
+ while True:
471
+ stream.literal_operator = False
472
+ token = lexer.get_token()
473
+ if token is None:
474
+ return tokens
475
+ # Quoted punctuation is an argument, never a boundary: it stays whole,
476
+ # and a quoted separator is marked so no scan stops at it (a path or
477
+ # branch can be named `|&`). Only real operator runs are split.
478
+ if stream.literal_operator:
479
+ tokens.append(
480
+ chr(0) + token if token in COMMAND_SEPARATORS else token
481
+ )
482
+ else:
483
+ tokens.extend(split_operators(token))
436
484
 
437
485
 
438
486
  def cluster_skips_verification(cluster):
@@ -1048,6 +1096,10 @@ def resolve_script(token):
1048
1096
  """
1049
1097
  if COMPUTED_VALUE.search(token):
1050
1098
  return (None, "a computed path the guard cannot resolve before the shell does")
1099
+ # A quoted separator carries a NUL marker from shell_tokens; the path the
1100
+ # shell passes is the argument without it.
1101
+ if token.startswith(chr(0)):
1102
+ token = token[1:]
1051
1103
  text = os.path.expanduser(token.strip().strip("'\""))
1052
1104
  # `bash -` and a bare `-` read the script from stdin; there is no file.
1053
1105
  if not text or text == "-":
@@ -1998,9 +2050,12 @@ def flat_tokens(text):
1998
2050
  # shlex otherwise makes quoted ";" indistinguishable from a real
1999
2051
  # command boundary. Keep literal destinations as data, so a setter
2000
2052
  # cannot masquerade as a one-operand configuration read.
2001
- if token in COMMAND_SEPARATORS and stream.literal_operator:
2002
- token = "\x00" + token
2003
- tokens.append(token)
2053
+ if stream.literal_operator:
2054
+ if token in COMMAND_SEPARATORS:
2055
+ token = "\x00" + token
2056
+ tokens.append(token)
2057
+ else:
2058
+ tokens.extend(split_operators(token))
2004
2059
  return [
2005
2060
  token
2006
2061
  for index, token in enumerate(tokens)
@@ -68,7 +68,10 @@ export const main = async (remoteArgs = process.argv.slice(2)) => {
68
68
  // Import/call inside this boundary instead of forwarding raw Node bootstrap
69
69
  // stderr. The scanner receives exactly the buffered Git stream.
70
70
  const { main: scan } = await import("./lisa-history-secrets.mjs");
71
- if ((await scan(["pre-push"], process.cwd(), input)) !== 0) failed = true;
71
+ // Git's remote arguments bound a new ref's range by what that remote
72
+ // already holds; without them a new branch rescans the whole history.
73
+ if ((await scan(["pre-push", ...remoteArgs], process.cwd(), input)) !== 0)
74
+ failed = true;
72
75
  } catch {
73
76
  console.error(
74
77
  "Managed history scanner is unavailable or damaged. Run full Lisa apply and retry; raw bootstrap errors are withheld."