@codyswann/lisa 4.39.0 → 4.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/all/copy-overwrite/scripts/lisa-cross-worktree-guard.mjs +333 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +17 -0
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +14 -0
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +28 -0
  5. package/dist/codex/scripts/block-no-verify.sh +21 -0
  6. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.js +22 -0
  8. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  9. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +19 -13
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +14 -0
  14. package/dist/opencode/plugin-templates/block-no-verify.sh +28 -0
  15. package/npm-package/create-only/.github/workflows/publish-to-npm.yml +48 -19
  16. package/package.json +4 -4
  17. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa/hooks/block-direct-issue-create.sh +17 -0
  20. package/plugins/lisa/hooks/block-managed-file-edits.sh +14 -0
  21. package/plugins/lisa/hooks/block-no-verify.agy.sh +21 -0
  22. package/plugins/lisa/hooks/block-no-verify.sh +28 -0
  23. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +17 -0
  24. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +14 -0
  25. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +21 -0
  26. package/plugins/lisa-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  30. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +17 -0
  34. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +14 -0
  35. package/plugins/lisa-copilot/hooks/block-no-verify.sh +28 -0
  36. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +17 -0
  38. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +14 -0
  39. package/plugins/lisa-cursor/hooks/block-no-verify.sh +28 -0
  40. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-agy/plugin.json +1 -1
  43. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  63. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails-agy/plugin.json +1 -1
  68. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  73. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  78. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/src/base/hooks/block-direct-issue-create.sh +17 -0
  81. package/plugins/src/base/hooks/block-managed-file-edits.sh +14 -0
  82. package/plugins/src/base/hooks/block-no-verify.agy.sh +21 -0
  83. package/plugins/src/base/hooks/block-no-verify.sh +28 -0
  84. package/rails/copy-contents/scripts/lisa-mutation.sh +8 -1
  85. package/scripts/two-channel-couplings.json +1 -1
  86. package/typescript/copy-contents/.husky/pre-commit +40 -0
  87. package/typescript/copy-overwrite/.prettierignore +1 -0
  88. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +20 -14
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+ /**
5
+ * Refuse a commit that stages a NEW file byte-identical to uncommitted work
6
+ * living in a different linked worktree.
7
+ *
8
+ * WHAT THIS IS FOR. When several agents share one repository through linked
9
+ * worktrees, one agent's uncommitted file can be committed by another — the
10
+ * file is real, the branch is real, the commit succeeds, every gate passes,
11
+ * and the history is quietly wrong about who did what. One confirmed case:
12
+ * a 284-line test file staged in one worktree was committed an hour later
13
+ * from elsewhere, byte-identical, under a subtly different subject line.
14
+ *
15
+ * WHY IT MUST BE A HOOK AND NOT A HABIT. The failure mode is that nobody
16
+ * notices. A check an agent has to remember to run is a check that does not
17
+ * run on the commit where it mattered. This runs on every commit, needs no
18
+ * cooperation, and costs one `stat` per linked worktree per newly added file.
19
+ *
20
+ * WHY CONTENT AND NOT MESSAGE. The observed case had a rewritten subject
21
+ * line, so anything keyed on the message would have missed it. Blob identity
22
+ * cannot be edited away.
23
+ *
24
+ * THE LIMIT, STATED PLAINLY. This needs BOTH halves — the file being staged
25
+ * here and the original still present over there. Once the other worktree is
26
+ * removed, the comparison has nothing to match against and this guard is
27
+ * silent. A clean run is therefore not proof that no work was taken; it is
28
+ * proof that none is takeable *right now*. Deleting a worktree that holds
29
+ * uncommitted work permanently forecloses the only window in which its own
30
+ * case was detectable.
31
+ */
32
+
33
+ import { execFileSync } from "node:child_process";
34
+ import { existsSync, statSync } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ /**
38
+ * Paths that are ambient session furniture rather than authored work.
39
+ *
40
+ * Matched with `startsWith` rather than a constructed RegExp: a literal prefix
41
+ * needs no pattern, and building one invites escaping that the shipped fixer
42
+ * then rewrites, which makes this file stop being a fixed point of Lisa's own
43
+ * formatter (#2788).
44
+ */
45
+ const IGNORED_PREFIXES = [".codex/"];
46
+
47
+ /**
48
+ * Content below this many bytes is not evidence of anything. A short or empty
49
+ * file collides across worktrees by coincidence — an empty `__init__`, a
50
+ * one-line barrel re-export, a placeholder — and a guard that fires on those
51
+ * teaches people to bypass it, which costs more than the cases it would catch.
52
+ */
53
+ const MIN_INTERESTING_BYTES = 64;
54
+
55
+ /**
56
+ * Header markers that identify a file as GENERATED rather than authored.
57
+ *
58
+ * Two agents running the same deterministic generator against the same source
59
+ * produce byte-identical output, in different worktrees, that is not yet on the
60
+ * integration branch. That is this guard's trigger condition exactly — and it
61
+ * is legitimate concurrent work rather than one agent taking another's.
62
+ *
63
+ * Measured against the real repository with two worktrees and the real
64
+ * generators: for an artifact that already EXISTS on the integration branch the
65
+ * case is excluded twice over — a regenerated file stages as a modification,
66
+ * not an addition, and it is tracked in the other worktree. Disabling either
67
+ * clause alone still accepted; only disabling BOTH made it refuse, which is how
68
+ * that redundancy was established rather than assumed.
69
+ *
70
+ * A generator that emits a file NOT yet on the integration branch has neither
71
+ * protection: it stages as an addition here and sits untracked over there. That
72
+ * case was reproduced and DID refuse, so this marker check is what closes it.
73
+ *
74
+ * Keyed on the file's own declaration rather than on a path list, because the
75
+ * distinction that matters is "identical because both were generated from the
76
+ * same source" versus "identical because it is the same authored file" — and a
77
+ * generated file says so at the top. A path list would need updating every time
78
+ * a generator learns a new output, and would be wrong silently until it was.
79
+ */
80
+ const GENERATED_MARKERS = Object.freeze([
81
+ "@generated",
82
+ "Generated by",
83
+ "DO NOT EDIT",
84
+ "Do not edit",
85
+ "managed by Lisa",
86
+ ]);
87
+
88
+ /**
89
+ * How far into a file the declaration must appear.
90
+ *
91
+ * A generated file announces itself in its header. Scanning the whole file
92
+ * would let the words "do not edit" occurring in ordinary prose — a rule, a
93
+ * skill, a comment quoting a banner — silence the guard for an authored file.
94
+ */
95
+ const GENERATED_HEADER_BYTES = 2048;
96
+
97
+ /**
98
+ * Run a command, returning its trimmed stdout, or `null` when it exits
99
+ * non-zero. Used for probes whose failure is a legitimate answer ("this path
100
+ * is not on main", "this path is not tracked there") rather than an error.
101
+ */
102
+ function probe(args, cwd) {
103
+ try {
104
+ return execFileSync("git", args, {
105
+ cwd,
106
+ encoding: "utf8",
107
+ stdio: ["ignore", "pipe", "ignore"],
108
+ }).trim();
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function must(args, cwd) {
115
+ return execFileSync("git", args, {
116
+ cwd,
117
+ encoding: "utf8",
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ }).trim();
120
+ }
121
+
122
+ /** Absolute paths of every registered linked worktree, this one included. */
123
+ function worktrees(repo) {
124
+ const out = probe(["worktree", "list", "--porcelain"], repo) ?? "";
125
+ return out
126
+ .split("\n")
127
+ .filter(line => line.startsWith("worktree "))
128
+ .map(line => line.slice("worktree ".length).trim())
129
+ .filter(Boolean);
130
+ }
131
+
132
+ /**
133
+ * Paths staged as ADDED in this commit.
134
+ *
135
+ * Deliberately only `A`. A modified tracked file is frequently identical
136
+ * across worktrees for honest reasons — a regenerated manifest is the common
137
+ * one, and it is deterministic, so two agents regenerating it produce the same
138
+ * bytes while differing from main. Firing on that would make the guard noise
139
+ * on nearly every commit. A brand-new file with identical content in two
140
+ * worktrees has no such innocent explanation.
141
+ */
142
+ function stagedAdditions(repo) {
143
+ const out = probe(
144
+ ["diff", "--cached", "--name-only", "--diff-filter=A", "-z"],
145
+ repo
146
+ );
147
+ if (out === null) return [];
148
+ return out.split("\0").filter(Boolean);
149
+ }
150
+
151
+ /** The blob this commit is staging for `path`, or null when unreadable. */
152
+ function stagedBlob(repo, path) {
153
+ return probe(["rev-parse", `:${path}`], repo);
154
+ }
155
+
156
+ /**
157
+ * Whether `blob` is simply what the integration branch already holds at
158
+ * `path`.
159
+ *
160
+ * THIS IS THE REJECTION CONTROL, and without it the guard is worthless rather
161
+ * than merely weak. Measured while designing this: a first pass without it
162
+ * fired on four files in one worktree that were byte-identical to `origin/main`
163
+ * — content every worktree carries for the ordinary reason. A guard that
164
+ * reports shared history as contamination fires on every worktree and is
165
+ * switched off within the hour.
166
+ */
167
+ function isIntegrationContent(repo, defaultRef, path, blob) {
168
+ if (!defaultRef) return false;
169
+ return probe(["rev-parse", `${defaultRef}:${path}`], repo) === blob;
170
+ }
171
+
172
+ /**
173
+ * Whether the blob's header declares it generated.
174
+ * @param {string} repo Repository root.
175
+ * @param {string} blob Blob sha to read.
176
+ * @returns {boolean} True when a generated marker appears in the header.
177
+ */
178
+ function declaresItselfGenerated(repo, blob) {
179
+ const body = probe(["cat-file", "blob", blob], repo);
180
+ if (body === null) return false;
181
+ const header = body.slice(0, GENERATED_HEADER_BYTES);
182
+ return GENERATED_MARKERS.some(marker => header.includes(marker));
183
+ }
184
+
185
+ /** The remote-tracking integration ref, or null when it cannot be resolved. */
186
+ function integrationRef(repo) {
187
+ const symref = probe(
188
+ ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
189
+ repo
190
+ );
191
+ if (symref) return symref.replace(/^refs\/remotes\//, "");
192
+ for (const candidate of ["origin/main", "origin/master"]) {
193
+ if (probe(["rev-parse", "--verify", "--quiet", candidate], repo))
194
+ return candidate;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Paths the operator has explicitly cleared, as a comma-separated list.
201
+ *
202
+ * Deliberately path-scoped rather than a boolean. A blanket "off" switch on a
203
+ * guard like this gets set once, in frustration, and never unset — and the
204
+ * next real case passes silently. Naming the file keeps the override as narrow
205
+ * as the situation that justified it.
206
+ */
207
+ function clearedPaths() {
208
+ const raw = process.env.LISA_ALLOW_CROSS_WORKTREE_BLOB;
209
+ if (!raw) return new Set();
210
+ return new Set(
211
+ raw
212
+ .split(",")
213
+ .map(entry => entry.trim())
214
+ .filter(Boolean)
215
+ );
216
+ }
217
+
218
+ function findCollisions(repo, self) {
219
+ const additions = stagedAdditions(repo);
220
+ if (additions.length === 0) return [];
221
+
222
+ const others = worktrees(repo).filter(tree => tree !== self);
223
+ if (others.length === 0) return [];
224
+
225
+ const defaultRef = integrationRef(repo);
226
+ const cleared = clearedPaths();
227
+ const collisions = [];
228
+
229
+ for (const path of additions) {
230
+ if (cleared.has(path)) continue;
231
+ if (IGNORED_PREFIXES.some(prefix => path.startsWith(prefix))) continue;
232
+
233
+ const blob = stagedBlob(repo, path);
234
+ if (!blob) continue;
235
+
236
+ const size = Number(probe(["cat-file", "-s", blob], repo));
237
+ if (!Number.isFinite(size) || size < MIN_INTERESTING_BYTES) continue;
238
+
239
+ if (isIntegrationContent(repo, defaultRef, path, blob)) continue;
240
+
241
+ if (declaresItselfGenerated(repo, blob)) continue;
242
+
243
+ for (const tree of others) {
244
+ const candidate = join(tree, path);
245
+ // The stat is the cheap filter: almost every worktree lacks the path,
246
+ // so almost every pair costs one failed stat and nothing else.
247
+ if (!existsSync(candidate)) continue;
248
+ let stats;
249
+ try {
250
+ stats = statSync(candidate);
251
+ } catch {
252
+ continue;
253
+ }
254
+ if (!stats.isFile()) continue;
255
+
256
+ const theirs = probe(["hash-object", candidate], repo);
257
+ if (theirs !== blob) continue;
258
+
259
+ // Tracked over there means it is shared history reaching that worktree
260
+ // through a branch, not that agent's work in progress.
261
+ const tracked = probe(["-C", tree, "ls-files", "--error-unmatch", path]);
262
+ if (tracked !== null) continue;
263
+
264
+ collisions.push({ path, tree });
265
+ break;
266
+ }
267
+ }
268
+
269
+ return collisions;
270
+ }
271
+
272
+ function report(collisions) {
273
+ const lines = [
274
+ "",
275
+ "❌ Commit blocked: staged content is also uncommitted in another worktree.",
276
+ "",
277
+ ];
278
+ for (const { path, tree } of collisions) {
279
+ lines.push(` ${path}`);
280
+ lines.push(` byte-identical to an uncommitted copy in ${tree}`);
281
+ }
282
+ lines.push("");
283
+ lines.push(" Each file above is being added here while the same bytes sit");
284
+ lines.push(
285
+ " uncommitted in another worktree. Either another agent authored it and"
286
+ );
287
+ lines.push(
288
+ " this commit would take credit for work in progress, or the same work"
289
+ );
290
+ lines.push(" is about to land twice on two branches.");
291
+ lines.push("");
292
+ lines.push(" Before retrying, establish which worktree authored it:");
293
+ lines.push(
294
+ " git log --all --oneline --find-object=$(git rev-parse :<path>)"
295
+ );
296
+ lines.push(
297
+ " Inspect the other branch through shared refs — git log, git show,"
298
+ );
299
+ lines.push(
300
+ " git cat-file all reach it from here. Do NOT enter another agent's"
301
+ );
302
+ lines.push(
303
+ " working tree, and do not delete it: it may hold the only copy."
304
+ );
305
+ lines.push("");
306
+ lines.push(" If this really is your own file, clear it by name:");
307
+ lines.push(
308
+ ` LISA_ALLOW_CROSS_WORKTREE_BLOB='${collisions.map(c => c.path).join(",")}' git commit ...`
309
+ );
310
+ lines.push("");
311
+ return lines.join("\n");
312
+ }
313
+
314
+ function main() {
315
+ let repo;
316
+ let self;
317
+ try {
318
+ repo = must(["rev-parse", "--show-toplevel"]);
319
+ self = repo;
320
+ } catch {
321
+ // Not a git repository, or git is unavailable. A commit is not happening
322
+ // here in any case; there is nothing for this guard to say.
323
+ return 0;
324
+ }
325
+
326
+ const collisions = findCollisions(repo, self);
327
+ if (collisions.length === 0) return 0;
328
+
329
+ process.stderr.write(report(collisions));
330
+ return 1;
331
+ }
332
+
333
+ process.exit(main());
@@ -1711,6 +1711,23 @@ def executed_operand(argv):
1711
1711
  # ordinary shell options and do not consume the script path.
1712
1712
  if not head.startswith("--") and "c" in head[1:]:
1713
1713
  return None
1714
+ # `-n` is noexec: the shell READS and parses the file and then
1715
+ # exits without running a line of it. `bash -n <file>` is the
1716
+ # one shape that puts a path at a command position while
1717
+ # provably executing nothing, so it is the exact seam between
1718
+ # reading and running — and this walk got it wrong in BOTH
1719
+ # directions (CodySwannGT/lisa#3781). A creation-shaped file
1720
+ # earned a false refusal; a file carrying a human-gate marker
1721
+ # earned a false ALLOW, adjudicating as "declared" a command
1722
+ # that filed nothing. The silent direction is the worse one.
1723
+ #
1724
+ # Not a read-only-command exemption: `bash <file>` still runs
1725
+ # the file and is still refused. The distinction is noexec, not
1726
+ # the program.
1727
+ if head == "--noexec" or (
1728
+ not head.startswith("--") and "n" in head[1:]
1729
+ ):
1730
+ return None
1714
1731
  elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1715
1732
  return None
1716
1733
  takes_value = head in INTERPRETER_VALUE_OPTIONS.get(program, set())
@@ -722,6 +722,20 @@ def shell_script_operand(args):
722
722
  # not a script file.
723
723
  if not token.startswith("--") and "c" in token[1:]:
724
724
  return None
725
+ # `-n` is noexec: the shell READS and parses the file, then exits
726
+ # without executing a line of it. `bash -n <file>` is the one shape
727
+ # that puts a path at a command position while provably running
728
+ # nothing — the exact seam between reading and running, and this
729
+ # walk got it wrong (CodySwannGT/lisa#3781). Here that meant a
730
+ # syntax check of any script mentioning a managed path was refused
731
+ # as though it were about to write one.
732
+ #
733
+ # `bash <file>` still executes and is still refused. Noexec is the
734
+ # distinction, not the program.
735
+ if token == "--noexec" or (
736
+ not token.startswith("--") and "n" in token[1:]
737
+ ):
738
+ return None
725
739
  continue
726
740
  return token
727
741
  return None
@@ -27,6 +27,13 @@
27
27
  # `source`, `.`, `bash < nv.sh`, and an interpreter a `find -exec` or
28
28
  # `xargs` puts at a command position.
29
29
  #
30
+ # NOT vector 11: `bash -n nv.sh`. `-n` is noexec — the shell reads and parses
31
+ # the file, then exits without running a line — so a syntax check is the one
32
+ # shape that puts a path at a command position while executing nothing. It used
33
+ # to be refused, which made `bash -n` unusable on this file, on every husky
34
+ # hook, and on most of this repository (#3781). The distinction is noexec, not
35
+ # the program: `bash nv.sh` still executes and is still refused.
36
+ #
30
37
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
31
38
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
32
39
  # "differs from mine", and guessing "behind" is how a fork's stronger guard gets
@@ -1034,6 +1041,10 @@ def resolve_script(token):
1034
1041
 
1035
1042
  SHELL_COMMAND_STRING = object()
1036
1043
  SHELL_STDIN = object()
1044
+ # `-n` / `--noexec`: the shell READS and parses its operand, then exits without
1045
+ # executing a line of it. Distinct from the two above because nothing runs at
1046
+ # all — there is no payload to scan and no stdin script to wait for.
1047
+ SHELL_NOEXEC = object()
1037
1048
 
1038
1049
 
1039
1050
  def shell_execution_operand(args):
@@ -1064,12 +1075,25 @@ def shell_execution_operand(args):
1064
1075
  index += 1
1065
1076
  continue
1066
1077
  if token.startswith("--"):
1078
+ if token == "--noexec":
1079
+ return SHELL_NOEXEC
1067
1080
  if token not in SHELL_LONG_OPTIONS_NO_VALUE:
1068
1081
  opaque_option_seen = True
1069
1082
  index += 1
1070
1083
  continue
1071
1084
  if "c" in token[1:]:
1072
1085
  return SHELL_COMMAND_STRING
1086
+ # `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
1087
+ # a command position while provably executing nothing, so it is the
1088
+ # exact seam between reading and running — and reach got it wrong in
1089
+ # BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
1090
+ # of any file merely DISCUSSING the bypass was refused, which is most of
1091
+ # this repository's own guards and every husky hook.
1092
+ #
1093
+ # `bash <file>` still executes and is still refused. Noexec is the
1094
+ # distinction, not the program: this is not a read-only exemption.
1095
+ if "n" in token[1:]:
1096
+ return SHELL_NOEXEC
1073
1097
  index += 1
1074
1098
  return args[index] if index < len(args) else SHELL_STDIN
1075
1099
 
@@ -1114,6 +1138,10 @@ def executed_scripts(tokens):
1114
1138
  continue
1115
1139
  if operand is SHELL_COMMAND_STRING:
1116
1140
  continue
1141
+ if operand is SHELL_NOEXEC:
1142
+ # Nothing runs, so there is nothing to follow and nothing
1143
+ # to fail closed about.
1144
+ continue
1117
1145
  else:
1118
1146
  continue
1119
1147
  if operand is None:
@@ -937,6 +937,10 @@ def resolve_script(token):
937
937
 
938
938
  SHELL_COMMAND_STRING = object()
939
939
  SHELL_STDIN = object()
940
+ # `-n` / `--noexec`: the shell READS and parses its operand, then exits without
941
+ # executing a line of it. Distinct from the two above because nothing runs at
942
+ # all — there is no payload to scan and no stdin script to wait for.
943
+ SHELL_NOEXEC = object()
940
944
 
941
945
 
942
946
  def shell_execution_operand(args):
@@ -967,12 +971,25 @@ def shell_execution_operand(args):
967
971
  index += 1
968
972
  continue
969
973
  if token.startswith("--"):
974
+ if token == "--noexec":
975
+ return SHELL_NOEXEC
970
976
  if token not in SHELL_LONG_OPTIONS_NO_VALUE:
971
977
  opaque_option_seen = True
972
978
  index += 1
973
979
  continue
974
980
  if "c" in token[1:]:
975
981
  return SHELL_COMMAND_STRING
982
+ # `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
983
+ # a command position while provably executing nothing, so it is the
984
+ # exact seam between reading and running — and reach got it wrong in
985
+ # BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
986
+ # of any file merely DISCUSSING the bypass was refused, which is most of
987
+ # this repository's own guards and every husky hook.
988
+ #
989
+ # `bash <file>` still executes and is still refused. Noexec is the
990
+ # distinction, not the program: this is not a read-only exemption.
991
+ if "n" in token[1:]:
992
+ return SHELL_NOEXEC
976
993
  index += 1
977
994
  return args[index] if index < len(args) else SHELL_STDIN
978
995
 
@@ -1017,6 +1034,10 @@ def executed_scripts(tokens):
1017
1034
  continue
1018
1035
  if operand is SHELL_COMMAND_STRING:
1019
1036
  continue
1037
+ if operand is SHELL_NOEXEC:
1038
+ # Nothing runs, so there is nothing to follow and nothing
1039
+ # to fail closed about.
1040
+ continue
1020
1041
  else:
1021
1042
  continue
1022
1043
  if operand is None:
@@ -1 +1 @@
1
- {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAigCjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAo6BjC,CAAC"}
1
+ {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA4gCjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA+6BjC,CAAC"}
@@ -404,6 +404,12 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
404
404
  "5d587fd849cef02e9706d084ac2b508f3885da0ba59b76311e3b666f4fbff01e",
405
405
  "99422b4912cd660ddeb5ec5ab9fc333e57fa5763e711117a00b06dd2eda0bbb7",
406
406
  ]),
407
+ "scripts/lisa-cross-worktree-guard.mjs": Object.freeze([
408
+ "3967e4eb189c043e8702ca5cff40c4612ed77027a053e23635eeb367a436cb9d",
409
+ "41b92e1e94a8ee9ad0235359dbd83566a49b17db60da933835057dc77008ab87",
410
+ "d8625e2d339ac512350203e29e70f7b20546bf5248cf952aafd997389feb230f",
411
+ "e0ef99ef1df32f76601a2106a2b68b3c31a126e0b48e8b8ceaba1a7476c32900",
412
+ ]),
407
413
  "scripts/lisa-destructive-guard.mjs": Object.freeze([
408
414
  "4fb431a3c58f443e303d613934d023d9e95ddae5c42710bb7009f7c080c82a64",
409
415
  "66ade2c9cc0554c4934ed94a44563fd9d648b37cacfa9a5d31f17ac4f181e5a3",
@@ -602,6 +608,8 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
602
608
  "2832037d6c8d7930a111519dee0cca0ed0110deba3988f8e713bb9203de254df",
603
609
  "2e030d5a394d3edb48c391db49661fb3d45545c85f88ea03d1ab07ad3787c089",
604
610
  "412209ea2329a64737eeffa4ffc9b6f9371f14cf86419fd0626361a99188a3f8",
611
+ "49fb071d5e062200b09babdc56002bc6bd8aceefd8508c49763c05d505f5083e",
612
+ "500b5d5770cf7a6bf5588effe6db462fe0a4646d1de3e8b799115279af1b2662",
605
613
  "5b3bd8a3460524457435f51a0994fb12009c97fffb921375bc79db3798acbabf",
606
614
  "5c5a9f1c39f99e493a575784f70379eb857c4963da5d400b6ee86f0e69fc5402",
607
615
  "60a4f94ec671b2372f7492680ab327d97aff686588ce85791f54e0fbd098b0ca",
@@ -627,6 +635,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
627
635
  "scripts/lisa-hooks/block-managed-file-edits.sh": Object.freeze([
628
636
  "133acf329582c5d4df9deb6a297df13216993c1bfec0b7df0663c69a1e2bb0db",
629
637
  "18aebaef5ea9bf6af220dc9beef80a5cfb37282c6a53047783b4cc96d8daa4e3",
638
+ "2bc98db04ded8aa64af5d894f485173fbfad81976cbd0c6938de6910acfbd577",
630
639
  "387a3f7f7dfbde9823532435fc1c7dbca687c8f3d1b63d9a435812b4a33e0425",
631
640
  "3882bd338e65b9db9e97aa9e70426f0f7a0d191539df51636e1a5e93a2501137",
632
641
  "38a52af7c9d5f36e9e68553bf68a7d1ced9e9f9e6cecdf615efc122d42c8b89e",
@@ -669,6 +678,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
669
678
  "dd1aea1b9fdd0da023bf704b331b350bc14620f4364afa5001c6690fcbc940cc",
670
679
  "e3407d63eae16662923d46b75b6a41e7ca7ab206b428ea2cbb0b4fee226f6516",
671
680
  "e5070ad1408dcd30f1ec44d8c4b9b914a57a9930c0fc9345bd6f28941b52afe3",
681
+ "e987dd924dbbdc35eddf607a27372bfac6cf743c9ddb888d8a81b4b128c10908",
672
682
  "efd20725ffa4a613823084bc00f1ab584353f8d7ed1c11672b0a0193aefe658c",
673
683
  ]),
674
684
  "scripts/lisa-hooks/block-shell-json-parsing.sh": Object.freeze([
@@ -752,6 +762,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
752
762
  "03dda43b5813b5b6a083c1e0302ce3e9fec13818864ed38dce180cbdaa4e2de2",
753
763
  "0cd013a51a81d8eeebced9f2646bdb3fe0a9247fcd74b57c03e95cc2db3fe7de",
754
764
  "0f5d9027e95d47745827adcd979e3c2355271066b6b84d661215dc6e895b3ac2",
765
+ "0fe73e162ece6c4a8270b0d5d675d6927a62962e0ae9275239c6acfacd0ef6c8",
755
766
  "11bc91eb0ab1f669fc6531201f16747dc8b5bfcebb1650ad8c29038e9ec8f89e",
756
767
  "19c77dd5ff81caf3ed92b5263b662bc16f9b43308993c6450cb6611bb037b180",
757
768
  "20bc2e58a54d7ef3c99b91393b85aa2f59d42984cbcbdb0a710b2c8d12d46b39",
@@ -1422,6 +1433,12 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1422
1433
  "scripts/lisa-commit-msg-gates.mjs": Object.freeze([
1423
1434
  "5d587fd849cef02e9706d084ac2b508f3885da0ba59b76311e3b666f4fbff01e",
1424
1435
  ]),
1436
+ "scripts/lisa-cross-worktree-guard.mjs": Object.freeze([
1437
+ "3967e4eb189c043e8702ca5cff40c4612ed77027a053e23635eeb367a436cb9d",
1438
+ "41b92e1e94a8ee9ad0235359dbd83566a49b17db60da933835057dc77008ab87",
1439
+ "d8625e2d339ac512350203e29e70f7b20546bf5248cf952aafd997389feb230f",
1440
+ "e0ef99ef1df32f76601a2106a2b68b3c31a126e0b48e8b8ceaba1a7476c32900",
1441
+ ]),
1425
1442
  "scripts/lisa-destructive-guard.mjs": Object.freeze([
1426
1443
  "4fb431a3c58f443e303d613934d023d9e95ddae5c42710bb7009f7c080c82a64",
1427
1444
  "66ade2c9cc0554c4934ed94a44563fd9d648b37cacfa9a5d31f17ac4f181e5a3",
@@ -1576,6 +1593,8 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1576
1593
  "2832037d6c8d7930a111519dee0cca0ed0110deba3988f8e713bb9203de254df",
1577
1594
  "2e030d5a394d3edb48c391db49661fb3d45545c85f88ea03d1ab07ad3787c089",
1578
1595
  "412209ea2329a64737eeffa4ffc9b6f9371f14cf86419fd0626361a99188a3f8",
1596
+ "49fb071d5e062200b09babdc56002bc6bd8aceefd8508c49763c05d505f5083e",
1597
+ "500b5d5770cf7a6bf5588effe6db462fe0a4646d1de3e8b799115279af1b2662",
1579
1598
  "5b3bd8a3460524457435f51a0994fb12009c97fffb921375bc79db3798acbabf",
1580
1599
  "5c5a9f1c39f99e493a575784f70379eb857c4963da5d400b6ee86f0e69fc5402",
1581
1600
  "60a4f94ec671b2372f7492680ab327d97aff686588ce85791f54e0fbd098b0ca",
@@ -1601,6 +1620,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1601
1620
  "scripts/lisa-hooks/block-managed-file-edits.sh": Object.freeze([
1602
1621
  "133acf329582c5d4df9deb6a297df13216993c1bfec0b7df0663c69a1e2bb0db",
1603
1622
  "18aebaef5ea9bf6af220dc9beef80a5cfb37282c6a53047783b4cc96d8daa4e3",
1623
+ "2bc98db04ded8aa64af5d894f485173fbfad81976cbd0c6938de6910acfbd577",
1604
1624
  "387a3f7f7dfbde9823532435fc1c7dbca687c8f3d1b63d9a435812b4a33e0425",
1605
1625
  "3882bd338e65b9db9e97aa9e70426f0f7a0d191539df51636e1a5e93a2501137",
1606
1626
  "38a52af7c9d5f36e9e68553bf68a7d1ced9e9f9e6cecdf615efc122d42c8b89e",
@@ -1643,6 +1663,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1643
1663
  "dd1aea1b9fdd0da023bf704b331b350bc14620f4364afa5001c6690fcbc940cc",
1644
1664
  "e3407d63eae16662923d46b75b6a41e7ca7ab206b428ea2cbb0b4fee226f6516",
1645
1665
  "e5070ad1408dcd30f1ec44d8c4b9b914a57a9930c0fc9345bd6f28941b52afe3",
1666
+ "e987dd924dbbdc35eddf607a27372bfac6cf743c9ddb888d8a81b4b128c10908",
1646
1667
  "efd20725ffa4a613823084bc00f1ab584353f8d7ed1c11672b0a0193aefe658c",
1647
1668
  ]),
1648
1669
  "scripts/lisa-hooks/block-shell-json-parsing.sh": Object.freeze([
@@ -1723,6 +1744,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1723
1744
  "scripts/lisa-mutation.mjs": Object.freeze([
1724
1745
  "0cd013a51a81d8eeebced9f2646bdb3fe0a9247fcd74b57c03e95cc2db3fe7de",
1725
1746
  "0f5d9027e95d47745827adcd979e3c2355271066b6b84d661215dc6e895b3ac2",
1747
+ "0fe73e162ece6c4a8270b0d5d675d6927a62962e0ae9275239c6acfacd0ef6c8",
1726
1748
  "19c77dd5ff81caf3ed92b5263b662bc16f9b43308993c6450cb6611bb037b180",
1727
1749
  "227dfa273802d6a5c03c3bf67136b743de98370127c7417db454f2e7424e98a0",
1728
1750
  "48394f771485db4b7863d2307f9ca0750cba4ce3750c615dd3c54fa47178a50e",