@codyswann/lisa 4.45.4 → 4.45.6

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 (81) hide show
  1. package/all/copy-overwrite/scripts/lisa-cross-worktree-guard.mjs +6 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +74 -0
  3. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +115 -23
  4. package/all/copy-overwrite/scripts/lisa-worktree-guard.mjs +456 -0
  5. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  6. package/dist/core/lisa-owned-hash-ledger.js +28 -0
  7. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  8. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  9. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.js +21 -10
  11. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  12. package/dist/opencode/plugin-templates/parity-safety-net.sh +74 -0
  13. package/package.json +5 -4
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/hooks/cleanup-stale-worktrees.sh +42 -1
  17. package/plugins/lisa/hooks/parity-safety-net.sh +74 -0
  18. package/plugins/lisa/hooks/worktree-binding-guard.mjs +115 -23
  19. package/plugins/lisa-agy/hooks/parity-safety-net.sh +74 -0
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  24. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/hooks/cleanup-stale-worktrees.sh +42 -1
  28. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +74 -0
  29. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +115 -23
  30. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cursor/hooks/cleanup-stale-worktrees.sh +42 -1
  32. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +74 -0
  33. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +115 -23
  34. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo-agy/plugin.json +1 -1
  37. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  57. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails-agy/plugin.json +1 -1
  62. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  67. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  72. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/src/base/hooks/cleanup-stale-worktrees.sh +42 -1
  75. package/plugins/src/base/hooks/parity-safety-net.sh +74 -0
  76. package/plugins/src/base/hooks/worktree-binding-guard.mjs +115 -23
  77. package/scripts/check-cwd-resolution-corpus.mjs +163 -0
  78. package/scripts/cleanup-worktrees.sh +36 -3
  79. package/scripts/two-channel-couplings.json +1 -1
  80. package/typescript/copy-overwrite/.prettierignore +1 -0
  81. package/typescript/create-only/.github/workflows/review-evidence.yml +20 -1
@@ -28,6 +28,12 @@
28
28
  * proof that none is takeable *right now*. Deleting a worktree that holds
29
29
  * uncommitted work permanently forecloses the only window in which its own
30
30
  * case was detectable.
31
+ *
32
+ * That limit is now held open from the other end by
33
+ * `lisa-worktree-guard.mjs` (CodySwannGT/lisa#3863), which refuses to remove a
34
+ * worktree whose uncommitted bytes exist in no commit. The two are one control
35
+ * read in two directions: this file needs the original to still be there, and
36
+ * that one is what keeps it there.
31
37
  */
32
38
 
33
39
  import { execFileSync } from "node:child_process";
@@ -2033,6 +2033,77 @@ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$
2033
2033
  # that could not run produced no lines, the loop body never executed, and the
2034
2034
  # guard was skipped with the command ALLOWED. A segmentation that cannot answer
2035
2035
  # must refuse, exactly like a scan that cannot answer.
2036
+ # 14b. Linked worktrees, which are the same principle one level out. `.git` is
2037
+ # protected because nothing in the working tree can rebuild it; a linked
2038
+ # worktree is protected because nothing OUTSIDE it can rebuild what it holds.
2039
+ # The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
2040
+ # the object store are shared by every worktree of a repository, while
2041
+ # `.git/index` and the working files are per-worktree. So `git commit` moves
2042
+ # work somewhere a deletion cannot reach, and `git add` does not — a staged file
2043
+ # shows green in `git status`, which reads as saved, and dies with the
2044
+ # directory.
2045
+ #
2046
+ # Measured twice in one evening (issue #3863): one worktree lost a 284-line
2047
+ # staged test file, and a second lost nothing from the identical event purely
2048
+ # because a gate had forced its work into a commit first. Neither deletion
2049
+ # announced itself. That is the second reason this is a block rather than a
2050
+ # warning — a worktree deletion destroys the only evidence its own case could
2051
+ # leave, so there is no after-the-fact detection to fall back on.
2052
+ #
2053
+ # `git worktree remove` (guard 12) already refuses a dirty tree, and
2054
+ # `--force` is already blocked. This closes the spelling that walks around both:
2055
+ # `rm -rf` on the directory, which git never sees.
2056
+ #
2057
+ # Matching is by REGISTERED path — `git worktree list` names them — so an
2058
+ # ordinary directory that merely lives near one is untouched. A repository that
2059
+ # cannot be read answers "no": this guard adds protection to a shape that was
2060
+ # fully allowed a moment ago, and failing it closed would deny every recursive
2061
+ # delete on every path outside a repository.
2062
+ #
2063
+ # The comparison is on WHOLE TOKENS, which is the only spelling that gets both
2064
+ # error directions right. Substring containment would let a worktree named
2065
+ # `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
2066
+ # hand-written boundary classes leak the other way, because `rm -rf <wt>/`
2067
+ # with one trailing slash ends on a character every such class treats as
2068
+ # "inside a path". So every non-path character — quote, space, parenthesis,
2069
+ # redirection — is folded to a space first, which makes each argument a
2070
+ # space-delimited token regardless of how it was quoted, and the match is then
2071
+ # an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
2072
+ # deliberately not this guard's business: it is not a worktree deletion, and
2073
+ # blocking routine cleanup inside a worktree is how a guard gets switched off.
2074
+ names_linked_worktree() {
2075
+ local stmt="$1" probe root listed line primary wt rel
2076
+ root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
2077
+ [ -n "$root" ] || return 1
2078
+ listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
2079
+ probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
2080
+ primary=""
2081
+ while IFS= read -r line; do
2082
+ case "$line" in
2083
+ worktree\ *) wt="${line#worktree }" ;;
2084
+ *) continue ;;
2085
+ esac
2086
+ # First entry is the primary checkout, which is not a linked worktree.
2087
+ if [ -z "$primary" ]; then
2088
+ primary="$wt"
2089
+ continue
2090
+ fi
2091
+ case "$probe" in
2092
+ *" $wt "* | *" $wt/ "*) return 0 ;;
2093
+ esac
2094
+ rel="${wt#"$primary"/}"
2095
+ [ "$rel" != "$wt" ] || continue
2096
+ # `./` is the same path spelled the way an agent in the project root types
2097
+ # it, and a token comparison is exact, so it has to be spelled here too.
2098
+ case "$probe" in
2099
+ *" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
2100
+ esac
2101
+ done <<EOF
2102
+ $listed
2103
+ EOF
2104
+ return 1
2105
+ }
2106
+
2036
2107
  git_segments_status=0
2037
2108
  git_segments="$(printf '%s' "$normalized_command_str" \
2038
2109
  | awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
@@ -2055,6 +2126,9 @@ while IFS= read -r git_stmt; do
2055
2126
  if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
2056
2127
  block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it). Delete a specific ignored artifact instead. Re-cloning is NOT a like-for-like replacement: every linked worktree stores a pointer into this .git, so discarding it discards each of them and any uncommitted work they hold — check \`git worktree list\` before treating the checkout as disposable."
2057
2128
  fi
2129
+ if names_linked_worktree "$git_stmt"; then
2130
+ block "recursive forced delete of a linked git worktree. A worktree's index and working files are private to it — only commits are shared — so this destroys any staged or unwritten-to-a-commit work another agent left there, and destroys the evidence that it did (CodySwannGT/lisa#3863). Use 'node scripts/lisa-worktree-guard.mjs remove <path>' instead: it deletes clean and already-committed trees without ceremony, names the files when they exist nowhere else, and records an explicit --force override."
2131
+ fi
2058
2132
  done <<<"$git_segments"
2059
2133
 
2060
2134
  # 15. Credential stores. Reading one into an agent transcript is disclosure even
@@ -307,6 +307,11 @@ function foreignTreeIn(text, bound, cwd) {
307
307
  for (const raw of code.match(/[^\s'";|&()<>]+/gu) ?? []) {
308
308
  const token = raw.replace(/^["']|["']$/gu, "");
309
309
  if (!token.includes("/")) continue;
310
+ // A null base means an unliteral `cd` left the directory this script runs
311
+ // from unknown. An ABSOLUTE token is unambiguous either way; a relative one
312
+ // is exactly the thing that cannot be placed, so it is skipped rather than
313
+ // resolved against a directory picked for having been handy.
314
+ if (!isAbsolute(token) && cwd === null) continue;
310
315
  const candidate = isAbsolute(token) ? token : join(cwd, token);
311
316
  let root;
312
317
  try {
@@ -320,6 +325,63 @@ function foreignTreeIn(text, bound, cwd) {
320
325
  return null;
321
326
  }
322
327
 
328
+ /**
329
+ * A `cd` target this guard cannot turn into a literal directory.
330
+ *
331
+ * Mirrors `parity-safety-net.sh`'s `follow_cd` test exactly, and the mirror is
332
+ * the point: two spellings of one rule are how the guards drift.
333
+ */
334
+ const UNLITERAL_CD_TARGET = /[$`*?[{]/u;
335
+
336
+ /**
337
+ * The literal target of a leading `cd`, `""` for a bare `cd`, or null when the
338
+ * segment does not change directory.
339
+ *
340
+ * A bare `cd` is `cd "$HOME"`. Leaving the previous directory in place for it
341
+ * would resolve later tokens from a directory the shell has already left.
342
+ * @param segment - One statement from the command
343
+ * @returns The target text, or null when this segment is not a `cd`
344
+ */
345
+ function cdTarget(segment) {
346
+ const tokens = tokenise(segment);
347
+ if (tokens.length === 0 || tokens[0] !== "cd") return null;
348
+ return tokens[1] ?? "";
349
+ }
350
+
351
+ /**
352
+ * Apply one `cd` to the directory currently in force.
353
+ *
354
+ * Contract points 1-4 of `cwd-resolution-corpus.json`: a literal target
355
+ * REPLACES the directory rather than joining a list of candidates — the
356
+ * ordering was #3933's entire defect; a relative target composes against the
357
+ * directory in force; a target that is not literal text yields unknown; and
358
+ * unknown is sticky only until the next resolvable `cd`, because this returns
359
+ * a fresh answer for each one.
360
+ * @param current - Directory in force, or null when it is already unknown
361
+ * @param target - Literal `cd` target, `""` for a bare `cd`
362
+ * @returns The new directory, or null when it cannot be known
363
+ */
364
+ function applyCd(current, target) {
365
+ const home = homedir();
366
+ let value = target === "" ? home : target;
367
+ if (value === "~") value = home;
368
+ else if (value.startsWith("~/")) value = join(home, value.slice(2));
369
+ if (
370
+ value === "" ||
371
+ value.startsWith("-") ||
372
+ UNLITERAL_CD_TARGET.test(value)
373
+ ) {
374
+ return null;
375
+ }
376
+ if (!isAbsolute(value) && current === null) return null;
377
+ const next = isAbsolute(value) ? resolve(value) : resolve(current, value);
378
+ try {
379
+ return statSync(next).isDirectory() ? next : null;
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
323
385
  /**
324
386
  * The foreign worktree an executed script reaches, or null.
325
387
  *
@@ -343,41 +405,71 @@ function foreignTreeIn(text, bound, cwd) {
343
405
  * disagreeing. The scripted form is the one neither covers, and it is the only
344
406
  * cell this arm fills.
345
407
  *
346
- * ## A relative script path is resolved against `payload.cwd`, and that is a
347
- * KNOWN limitation shared with `parity-safety-net.sh`
408
+ * ## A relative script path is resolved against the cwd the command will have
409
+ *
410
+ * A leading `cd` establishes the directory this guard resolves relative tokens
411
+ * from, because that is the directory the shell will be in once the command
412
+ * runs. Resolving against `payload.cwd` instead was CodySwannGT/lisa#3933's
413
+ * defect, inherited here knowingly and fixed in CodySwannGT/lisa#3952: on a
414
+ * machine running many worktrees of one repository, every tree holds its own
415
+ * copy of every script at the same relative path, and they differ by whatever
416
+ * each lane is doing.
348
417
  *
349
- * CodySwannGT/lisa#3933, filed against that guard, is the same defect: a
350
- * relative token is resolved against the hook process's working directory, not
351
- * against the directory the command will have once it runs its own leading
352
- * `cd`. So `cd <other tree> && bash scripts/foo.sh` reads THIS tree's copy of
353
- * `scripts/foo.sh` and reports on it with full confidence.
418
+ * ## One contract, two implementations, and why that is the honest answer
354
419
  *
355
- * It matters here for the same reason it matters there: on a machine running
356
- * many worktrees of one repository, every tree holds its own copy of every
357
- * script at the same relative path, and they differ by whatever each lane is
358
- * doing. A confident scan of the wrong copy is wrong in both directions, and
359
- * the silent one — the copy that will run reaches out, the copy that was read
360
- * does not — is the direction this arm exists to prevent.
420
+ * `parity-safety-net.sh` answers this same question — "which file will this
421
+ * command actually execute?" — in POSIX shell, where `follow_cd` mutates
422
+ * file-scope state that `follow_locate` reads. This guard is Node. **There is
423
+ * no call that reaches from here into a shell function's file-scope state**, so
424
+ * the duplication is not a factoring somebody skipped; it is one idea that
425
+ * cannot cross a language boundary as a function call.
361
426
  *
362
- * NOT fixed here on purpose. #3933 is assigned and its options (honour a
363
- * leading `cd`, or fail closed when the effective cwd is uncertain) belong in
364
- * one place rather than two: a second implementation of the same resolution is
365
- * how two guards drift into disagreeing about which file a command runs.
366
- * When that lands, this call site should adopt whatever it produces.
427
+ * What binds the two is a written contract and a corpus of rows that BOTH
428
+ * guards are driven over in CI, so a divergence fails a test on the commit that
429
+ * causes it rather than surfacing later as a wrong verdict. Contract and corpus
430
+ * live in `tests/unit/hooks/support/cwd-resolution-corpus.json`.
367
431
  *
368
- * The exposure here is narrower than there. This arm is reached only after the
369
- * session has been confirmed to be in its bound worktree, so the common case
370
- * is a relative path resolving inside the tree the session is actually in.
432
+ * This is explicitly NOT unification, and CodySwannGT/lisa#3952 records it as
433
+ * such: two implementations remain. **A behaviour with no row in the corpus is
434
+ * free to diverge — add a row when you add a branch.**
435
+ *
436
+ * ## The two guards agree on the RESOLUTION, not on the verdict
437
+ *
438
+ * When the effective directory cannot be known, both guards decline to resolve
439
+ * the token. What they then do differs, and that difference is deliberate:
440
+ * `parity-safety-net.sh` refuses, because a destructive command it cannot see
441
+ * is the thing it exists to stop. This guard fails open, which is the doctrine
442
+ * every other undecidable case in this file follows. The corpus asserts the
443
+ * resolved path, not the exit code, precisely so that one contract can bind two
444
+ * guards whose fail directions are correctly different.
371
445
  *
372
446
  * Fails OPEN and says so, like every other undecidable case in this file.
373
447
  */
374
448
  function scriptReachingForeignTree(payload, bound) {
375
449
  const command = payload.tool_input?.command;
376
450
  if (typeof command !== "string" || !command) return null;
451
+ let cwd = payload.cwd;
377
452
  for (const segment of command.split(COMMAND_SEPARATORS)) {
453
+ const target = cdTarget(segment);
454
+ if (target !== null) {
455
+ cwd = applyCd(cwd, target);
456
+ continue;
457
+ }
378
458
  const named = executedPath(segment);
379
459
  if (!named) continue;
380
- const path = isAbsolute(named) ? named : join(payload.cwd, named);
460
+ if (!isAbsolute(named) && cwd === null) {
461
+ // Contract point 3: the effective directory is unknown, so WHICH copy of
462
+ // a same-named script would run is unknown too. Declining to resolve is
463
+ // the half shared with parity-safety-net.sh; failing open rather than
464
+ // refusing is this guard's own doctrine, and the corpus binds the two
465
+ // guards on the resolution precisely so it does not have to bind them
466
+ // on a verdict one of them would be wrong to reach.
467
+ say(
468
+ `cannot tell which directory ${named} would run from; NOT enforcing on it`
469
+ );
470
+ continue;
471
+ }
472
+ const path = isAbsolute(named) ? resolve(named) : resolve(cwd, named);
381
473
  let text;
382
474
  try {
383
475
  if (statSync(path).size > SCRIPT_READ_LIMIT) {
@@ -389,7 +481,7 @@ function scriptReachingForeignTree(payload, bound) {
389
481
  // Not a readable file: nothing was executed that this can read.
390
482
  continue;
391
483
  }
392
- const foreign = foreignTreeIn(text, bound, payload.cwd);
484
+ const foreign = foreignTreeIn(text, bound, cwd);
393
485
  if (foreign) return { script: path, foreign };
394
486
  }
395
487
  return null;