@codyswann/lisa 4.45.5 → 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.
- package/all/copy-overwrite/scripts/lisa-cross-worktree-guard.mjs +6 -0
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +74 -0
- package/all/copy-overwrite/scripts/lisa-worktree-guard.mjs +456 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +24 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +13 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +74 -0
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/cleanup-stale-worktrees.sh +42 -1
- package/plugins/lisa/hooks/parity-safety-net.sh +74 -0
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +74 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/cleanup-stale-worktrees.sh +42 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +74 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/cleanup-stale-worktrees.sh +42 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +74 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/cleanup-stale-worktrees.sh +42 -1
- package/plugins/src/base/hooks/parity-safety-net.sh +74 -0
- package/scripts/cleanup-worktrees.sh +36 -3
- package/scripts/two-channel-couplings.json +1 -1
- package/typescript/copy-overwrite/.prettierignore +1 -0
- package/typescript/create-only/.github/workflows/review-evidence.yml +20 -1
|
@@ -2030,6 +2030,77 @@ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$
|
|
|
2030
2030
|
# that could not run produced no lines, the loop body never executed, and the
|
|
2031
2031
|
# guard was skipped with the command ALLOWED. A segmentation that cannot answer
|
|
2032
2032
|
# must refuse, exactly like a scan that cannot answer.
|
|
2033
|
+
# 14b. Linked worktrees, which are the same principle one level out. `.git` is
|
|
2034
|
+
# protected because nothing in the working tree can rebuild it; a linked
|
|
2035
|
+
# worktree is protected because nothing OUTSIDE it can rebuild what it holds.
|
|
2036
|
+
# The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
|
|
2037
|
+
# the object store are shared by every worktree of a repository, while
|
|
2038
|
+
# `.git/index` and the working files are per-worktree. So `git commit` moves
|
|
2039
|
+
# work somewhere a deletion cannot reach, and `git add` does not — a staged file
|
|
2040
|
+
# shows green in `git status`, which reads as saved, and dies with the
|
|
2041
|
+
# directory.
|
|
2042
|
+
#
|
|
2043
|
+
# Measured twice in one evening (issue #3863): one worktree lost a 284-line
|
|
2044
|
+
# staged test file, and a second lost nothing from the identical event purely
|
|
2045
|
+
# because a gate had forced its work into a commit first. Neither deletion
|
|
2046
|
+
# announced itself. That is the second reason this is a block rather than a
|
|
2047
|
+
# warning — a worktree deletion destroys the only evidence its own case could
|
|
2048
|
+
# leave, so there is no after-the-fact detection to fall back on.
|
|
2049
|
+
#
|
|
2050
|
+
# `git worktree remove` (guard 12) already refuses a dirty tree, and
|
|
2051
|
+
# `--force` is already blocked. This closes the spelling that walks around both:
|
|
2052
|
+
# `rm -rf` on the directory, which git never sees.
|
|
2053
|
+
#
|
|
2054
|
+
# Matching is by REGISTERED path — `git worktree list` names them — so an
|
|
2055
|
+
# ordinary directory that merely lives near one is untouched. A repository that
|
|
2056
|
+
# cannot be read answers "no": this guard adds protection to a shape that was
|
|
2057
|
+
# fully allowed a moment ago, and failing it closed would deny every recursive
|
|
2058
|
+
# delete on every path outside a repository.
|
|
2059
|
+
#
|
|
2060
|
+
# The comparison is on WHOLE TOKENS, which is the only spelling that gets both
|
|
2061
|
+
# error directions right. Substring containment would let a worktree named
|
|
2062
|
+
# `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
|
|
2063
|
+
# hand-written boundary classes leak the other way, because `rm -rf <wt>/`
|
|
2064
|
+
# with one trailing slash ends on a character every such class treats as
|
|
2065
|
+
# "inside a path". So every non-path character — quote, space, parenthesis,
|
|
2066
|
+
# redirection — is folded to a space first, which makes each argument a
|
|
2067
|
+
# space-delimited token regardless of how it was quoted, and the match is then
|
|
2068
|
+
# an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
|
|
2069
|
+
# deliberately not this guard's business: it is not a worktree deletion, and
|
|
2070
|
+
# blocking routine cleanup inside a worktree is how a guard gets switched off.
|
|
2071
|
+
names_linked_worktree() {
|
|
2072
|
+
local stmt="$1" probe root listed line primary wt rel
|
|
2073
|
+
root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
|
|
2074
|
+
[ -n "$root" ] || return 1
|
|
2075
|
+
listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
|
|
2076
|
+
probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
|
|
2077
|
+
primary=""
|
|
2078
|
+
while IFS= read -r line; do
|
|
2079
|
+
case "$line" in
|
|
2080
|
+
worktree\ *) wt="${line#worktree }" ;;
|
|
2081
|
+
*) continue ;;
|
|
2082
|
+
esac
|
|
2083
|
+
# First entry is the primary checkout, which is not a linked worktree.
|
|
2084
|
+
if [ -z "$primary" ]; then
|
|
2085
|
+
primary="$wt"
|
|
2086
|
+
continue
|
|
2087
|
+
fi
|
|
2088
|
+
case "$probe" in
|
|
2089
|
+
*" $wt "* | *" $wt/ "*) return 0 ;;
|
|
2090
|
+
esac
|
|
2091
|
+
rel="${wt#"$primary"/}"
|
|
2092
|
+
[ "$rel" != "$wt" ] || continue
|
|
2093
|
+
# `./` is the same path spelled the way an agent in the project root types
|
|
2094
|
+
# it, and a token comparison is exact, so it has to be spelled here too.
|
|
2095
|
+
case "$probe" in
|
|
2096
|
+
*" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
|
|
2097
|
+
esac
|
|
2098
|
+
done <<EOF
|
|
2099
|
+
$listed
|
|
2100
|
+
EOF
|
|
2101
|
+
return 1
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2033
2104
|
git_segments_status=0
|
|
2034
2105
|
git_segments="$(printf '%s' "$normalized_command_str" \
|
|
2035
2106
|
| awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
|
|
@@ -2052,6 +2123,9 @@ while IFS= read -r git_stmt; do
|
|
|
2052
2123
|
if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
|
|
2053
2124
|
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."
|
|
2054
2125
|
fi
|
|
2126
|
+
if names_linked_worktree "$git_stmt"; then
|
|
2127
|
+
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."
|
|
2128
|
+
fi
|
|
2055
2129
|
done <<<"$git_segments"
|
|
2056
2130
|
|
|
2057
2131
|
# 15. Credential stores. Reading one into an agent transcript is disclosure even
|
package/package.json
CHANGED
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
"zod-validation-error": "^4.0.0"
|
|
177
177
|
},
|
|
178
178
|
"name": "@codyswann/lisa",
|
|
179
|
-
"version": "4.45.
|
|
179
|
+
"version": "4.45.6",
|
|
180
180
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
181
181
|
"main": "dist/index.js",
|
|
182
182
|
"exports": {
|
|
@@ -324,7 +324,7 @@
|
|
|
324
324
|
"test": "tests"
|
|
325
325
|
},
|
|
326
326
|
"types": "./dist/index.d.ts",
|
|
327
|
-
"lisaReleaseCommit": "
|
|
328
|
-
"gitHead": "
|
|
329
|
-
"lisaReleaseTag": "v4.45.
|
|
327
|
+
"lisaReleaseCommit": "51043b81af85673a4ee2956bf05e65a8f6bda03f",
|
|
328
|
+
"gitHead": "51043b81af85673a4ee2956bf05e65a8f6bda03f",
|
|
329
|
+
"lisaReleaseTag": "v4.45.6"
|
|
330
330
|
}
|
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
# * no modified or staged TRACKED files (real work is never deleted)
|
|
17
17
|
# * its HEAD commit is reachable from some remote ref (nothing unpushed)
|
|
18
18
|
# * its directory mtime is older than LISA_WORKTREE_MAX_AGE_DAYS (default 7)
|
|
19
|
+
# * lisa-worktree-guard reports no uncommitted bytes that exist in no commit
|
|
19
20
|
# Untracked-only dirt does NOT block removal — that junk is exactly what
|
|
20
|
-
# defeats the built-in sweep.
|
|
21
|
+
# defeats the built-in sweep. But "untracked" and "junk" are not the same word:
|
|
22
|
+
# a brand-new source file an agent has written and not yet committed is
|
|
23
|
+
# untracked too, and this sweep would delete it (CodySwannGT/lisa#3863). The
|
|
24
|
+
# guard draws the line by CONTENT rather than by tracking state — a file whose
|
|
25
|
+
# exact bytes are already in some commit is free to delete, one whose bytes
|
|
26
|
+
# exist nowhere else is not, and ignored paths (node_modules, build output)
|
|
27
|
+
# never reach the question. Set LISA_WORKTREE_CLEANUP=off to disable.
|
|
21
28
|
|
|
22
29
|
set -u
|
|
23
30
|
|
|
@@ -37,6 +44,33 @@ esac
|
|
|
37
44
|
wt_root="$repo_root/.claude/worktrees"
|
|
38
45
|
[ -d "$wt_root" ] || exit 0
|
|
39
46
|
|
|
47
|
+
# Resolve the content-reachability guard once. Three homes, in the order a host
|
|
48
|
+
# project actually has them: the applied copy, the installed package, and Lisa's
|
|
49
|
+
# own delivery tree (Lisa does not apply this script to itself).
|
|
50
|
+
guard=""
|
|
51
|
+
for candidate in \
|
|
52
|
+
"$repo_root/scripts/lisa-worktree-guard.mjs" \
|
|
53
|
+
"$repo_root/node_modules/@codyswann/lisa/all/copy-overwrite/scripts/lisa-worktree-guard.mjs" \
|
|
54
|
+
"$repo_root/all/copy-overwrite/scripts/lisa-worktree-guard.mjs"; do
|
|
55
|
+
if [ -f "$candidate" ]; then
|
|
56
|
+
guard="$candidate"
|
|
57
|
+
break
|
|
58
|
+
fi
|
|
59
|
+
done
|
|
60
|
+
command -v node > /dev/null 2>&1 || guard=""
|
|
61
|
+
|
|
62
|
+
# Whether a worktree's uncommitted content is safe to destroy. With the guard
|
|
63
|
+
# present the question is answered by content; without it (no node, no delivered
|
|
64
|
+
# script) the sweep falls back to the conservative shape — ANY non-ignored
|
|
65
|
+
# untracked file keeps the tree. A missing guard must not read as consent.
|
|
66
|
+
removal_allowed() {
|
|
67
|
+
if [ -n "$guard" ]; then
|
|
68
|
+
node "$guard" check "$1" > /dev/null 2>&1
|
|
69
|
+
return $?
|
|
70
|
+
fi
|
|
71
|
+
[ -z "$(git -C "$1" ls-files --others --exclude-standard 2> /dev/null | head -1)" ]
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
now=$(date +%s)
|
|
41
75
|
max_age_secs=$((MAX_AGE_DAYS * 86400))
|
|
42
76
|
removed=0
|
|
@@ -66,6 +100,13 @@ for wt in "$wt_root"/*/; do
|
|
|
66
100
|
sha=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || continue
|
|
67
101
|
[ -n "$(git -C "$wt" branch -r --contains "$sha" 2>/dev/null | head -1)" ] || continue
|
|
68
102
|
|
|
103
|
+
# Content gate: refuse to destroy bytes that live in no commit. The
|
|
104
|
+
# tracked-file check above answers a different question — a file an agent
|
|
105
|
+
# has written but never `git add`ed is untracked, so that check reports the
|
|
106
|
+
# tree clean while the whole deliverable sits in it. Only commits are shared
|
|
107
|
+
# between worktrees; this directory's working files and index are not.
|
|
108
|
+
removal_allowed "$wt" || continue
|
|
109
|
+
|
|
69
110
|
# git worktree lock (held during live agent execution) blocks removal;
|
|
70
111
|
# --force only clears untracked junk, never the gates above.
|
|
71
112
|
git -C "$repo_root" worktree remove --force "$wt" 2>/dev/null && removed=$((removed + 1))
|
|
@@ -2030,6 +2030,77 @@ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$
|
|
|
2030
2030
|
# that could not run produced no lines, the loop body never executed, and the
|
|
2031
2031
|
# guard was skipped with the command ALLOWED. A segmentation that cannot answer
|
|
2032
2032
|
# must refuse, exactly like a scan that cannot answer.
|
|
2033
|
+
# 14b. Linked worktrees, which are the same principle one level out. `.git` is
|
|
2034
|
+
# protected because nothing in the working tree can rebuild it; a linked
|
|
2035
|
+
# worktree is protected because nothing OUTSIDE it can rebuild what it holds.
|
|
2036
|
+
# The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
|
|
2037
|
+
# the object store are shared by every worktree of a repository, while
|
|
2038
|
+
# `.git/index` and the working files are per-worktree. So `git commit` moves
|
|
2039
|
+
# work somewhere a deletion cannot reach, and `git add` does not — a staged file
|
|
2040
|
+
# shows green in `git status`, which reads as saved, and dies with the
|
|
2041
|
+
# directory.
|
|
2042
|
+
#
|
|
2043
|
+
# Measured twice in one evening (issue #3863): one worktree lost a 284-line
|
|
2044
|
+
# staged test file, and a second lost nothing from the identical event purely
|
|
2045
|
+
# because a gate had forced its work into a commit first. Neither deletion
|
|
2046
|
+
# announced itself. That is the second reason this is a block rather than a
|
|
2047
|
+
# warning — a worktree deletion destroys the only evidence its own case could
|
|
2048
|
+
# leave, so there is no after-the-fact detection to fall back on.
|
|
2049
|
+
#
|
|
2050
|
+
# `git worktree remove` (guard 12) already refuses a dirty tree, and
|
|
2051
|
+
# `--force` is already blocked. This closes the spelling that walks around both:
|
|
2052
|
+
# `rm -rf` on the directory, which git never sees.
|
|
2053
|
+
#
|
|
2054
|
+
# Matching is by REGISTERED path — `git worktree list` names them — so an
|
|
2055
|
+
# ordinary directory that merely lives near one is untouched. A repository that
|
|
2056
|
+
# cannot be read answers "no": this guard adds protection to a shape that was
|
|
2057
|
+
# fully allowed a moment ago, and failing it closed would deny every recursive
|
|
2058
|
+
# delete on every path outside a repository.
|
|
2059
|
+
#
|
|
2060
|
+
# The comparison is on WHOLE TOKENS, which is the only spelling that gets both
|
|
2061
|
+
# error directions right. Substring containment would let a worktree named
|
|
2062
|
+
# `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
|
|
2063
|
+
# hand-written boundary classes leak the other way, because `rm -rf <wt>/`
|
|
2064
|
+
# with one trailing slash ends on a character every such class treats as
|
|
2065
|
+
# "inside a path". So every non-path character — quote, space, parenthesis,
|
|
2066
|
+
# redirection — is folded to a space first, which makes each argument a
|
|
2067
|
+
# space-delimited token regardless of how it was quoted, and the match is then
|
|
2068
|
+
# an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
|
|
2069
|
+
# deliberately not this guard's business: it is not a worktree deletion, and
|
|
2070
|
+
# blocking routine cleanup inside a worktree is how a guard gets switched off.
|
|
2071
|
+
names_linked_worktree() {
|
|
2072
|
+
local stmt="$1" probe root listed line primary wt rel
|
|
2073
|
+
root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
|
|
2074
|
+
[ -n "$root" ] || return 1
|
|
2075
|
+
listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
|
|
2076
|
+
probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
|
|
2077
|
+
primary=""
|
|
2078
|
+
while IFS= read -r line; do
|
|
2079
|
+
case "$line" in
|
|
2080
|
+
worktree\ *) wt="${line#worktree }" ;;
|
|
2081
|
+
*) continue ;;
|
|
2082
|
+
esac
|
|
2083
|
+
# First entry is the primary checkout, which is not a linked worktree.
|
|
2084
|
+
if [ -z "$primary" ]; then
|
|
2085
|
+
primary="$wt"
|
|
2086
|
+
continue
|
|
2087
|
+
fi
|
|
2088
|
+
case "$probe" in
|
|
2089
|
+
*" $wt "* | *" $wt/ "*) return 0 ;;
|
|
2090
|
+
esac
|
|
2091
|
+
rel="${wt#"$primary"/}"
|
|
2092
|
+
[ "$rel" != "$wt" ] || continue
|
|
2093
|
+
# `./` is the same path spelled the way an agent in the project root types
|
|
2094
|
+
# it, and a token comparison is exact, so it has to be spelled here too.
|
|
2095
|
+
case "$probe" in
|
|
2096
|
+
*" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
|
|
2097
|
+
esac
|
|
2098
|
+
done <<EOF
|
|
2099
|
+
$listed
|
|
2100
|
+
EOF
|
|
2101
|
+
return 1
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2033
2104
|
git_segments_status=0
|
|
2034
2105
|
git_segments="$(printf '%s' "$normalized_command_str" \
|
|
2035
2106
|
| awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
|
|
@@ -2052,6 +2123,9 @@ while IFS= read -r git_stmt; do
|
|
|
2052
2123
|
if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
|
|
2053
2124
|
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."
|
|
2054
2125
|
fi
|
|
2126
|
+
if names_linked_worktree "$git_stmt"; then
|
|
2127
|
+
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."
|
|
2128
|
+
fi
|
|
2055
2129
|
done <<<"$git_segments"
|
|
2056
2130
|
|
|
2057
2131
|
# 15. Credential stores. Reading one into an agent transcript is disclosure even
|
|
@@ -2030,6 +2030,77 @@ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$
|
|
|
2030
2030
|
# that could not run produced no lines, the loop body never executed, and the
|
|
2031
2031
|
# guard was skipped with the command ALLOWED. A segmentation that cannot answer
|
|
2032
2032
|
# must refuse, exactly like a scan that cannot answer.
|
|
2033
|
+
# 14b. Linked worktrees, which are the same principle one level out. `.git` is
|
|
2034
|
+
# protected because nothing in the working tree can rebuild it; a linked
|
|
2035
|
+
# worktree is protected because nothing OUTSIDE it can rebuild what it holds.
|
|
2036
|
+
# The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
|
|
2037
|
+
# the object store are shared by every worktree of a repository, while
|
|
2038
|
+
# `.git/index` and the working files are per-worktree. So `git commit` moves
|
|
2039
|
+
# work somewhere a deletion cannot reach, and `git add` does not — a staged file
|
|
2040
|
+
# shows green in `git status`, which reads as saved, and dies with the
|
|
2041
|
+
# directory.
|
|
2042
|
+
#
|
|
2043
|
+
# Measured twice in one evening (issue #3863): one worktree lost a 284-line
|
|
2044
|
+
# staged test file, and a second lost nothing from the identical event purely
|
|
2045
|
+
# because a gate had forced its work into a commit first. Neither deletion
|
|
2046
|
+
# announced itself. That is the second reason this is a block rather than a
|
|
2047
|
+
# warning — a worktree deletion destroys the only evidence its own case could
|
|
2048
|
+
# leave, so there is no after-the-fact detection to fall back on.
|
|
2049
|
+
#
|
|
2050
|
+
# `git worktree remove` (guard 12) already refuses a dirty tree, and
|
|
2051
|
+
# `--force` is already blocked. This closes the spelling that walks around both:
|
|
2052
|
+
# `rm -rf` on the directory, which git never sees.
|
|
2053
|
+
#
|
|
2054
|
+
# Matching is by REGISTERED path — `git worktree list` names them — so an
|
|
2055
|
+
# ordinary directory that merely lives near one is untouched. A repository that
|
|
2056
|
+
# cannot be read answers "no": this guard adds protection to a shape that was
|
|
2057
|
+
# fully allowed a moment ago, and failing it closed would deny every recursive
|
|
2058
|
+
# delete on every path outside a repository.
|
|
2059
|
+
#
|
|
2060
|
+
# The comparison is on WHOLE TOKENS, which is the only spelling that gets both
|
|
2061
|
+
# error directions right. Substring containment would let a worktree named
|
|
2062
|
+
# `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
|
|
2063
|
+
# hand-written boundary classes leak the other way, because `rm -rf <wt>/`
|
|
2064
|
+
# with one trailing slash ends on a character every such class treats as
|
|
2065
|
+
# "inside a path". So every non-path character — quote, space, parenthesis,
|
|
2066
|
+
# redirection — is folded to a space first, which makes each argument a
|
|
2067
|
+
# space-delimited token regardless of how it was quoted, and the match is then
|
|
2068
|
+
# an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
|
|
2069
|
+
# deliberately not this guard's business: it is not a worktree deletion, and
|
|
2070
|
+
# blocking routine cleanup inside a worktree is how a guard gets switched off.
|
|
2071
|
+
names_linked_worktree() {
|
|
2072
|
+
local stmt="$1" probe root listed line primary wt rel
|
|
2073
|
+
root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
|
|
2074
|
+
[ -n "$root" ] || return 1
|
|
2075
|
+
listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
|
|
2076
|
+
probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
|
|
2077
|
+
primary=""
|
|
2078
|
+
while IFS= read -r line; do
|
|
2079
|
+
case "$line" in
|
|
2080
|
+
worktree\ *) wt="${line#worktree }" ;;
|
|
2081
|
+
*) continue ;;
|
|
2082
|
+
esac
|
|
2083
|
+
# First entry is the primary checkout, which is not a linked worktree.
|
|
2084
|
+
if [ -z "$primary" ]; then
|
|
2085
|
+
primary="$wt"
|
|
2086
|
+
continue
|
|
2087
|
+
fi
|
|
2088
|
+
case "$probe" in
|
|
2089
|
+
*" $wt "* | *" $wt/ "*) return 0 ;;
|
|
2090
|
+
esac
|
|
2091
|
+
rel="${wt#"$primary"/}"
|
|
2092
|
+
[ "$rel" != "$wt" ] || continue
|
|
2093
|
+
# `./` is the same path spelled the way an agent in the project root types
|
|
2094
|
+
# it, and a token comparison is exact, so it has to be spelled here too.
|
|
2095
|
+
case "$probe" in
|
|
2096
|
+
*" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
|
|
2097
|
+
esac
|
|
2098
|
+
done <<EOF
|
|
2099
|
+
$listed
|
|
2100
|
+
EOF
|
|
2101
|
+
return 1
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2033
2104
|
git_segments_status=0
|
|
2034
2105
|
git_segments="$(printf '%s' "$normalized_command_str" \
|
|
2035
2106
|
| awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
|
|
@@ -2052,6 +2123,9 @@ while IFS= read -r git_stmt; do
|
|
|
2052
2123
|
if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
|
|
2053
2124
|
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."
|
|
2054
2125
|
fi
|
|
2126
|
+
if names_linked_worktree "$git_stmt"; then
|
|
2127
|
+
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."
|
|
2128
|
+
fi
|
|
2055
2129
|
done <<<"$git_segments"
|
|
2056
2130
|
|
|
2057
2131
|
# 15. Credential stores. Reading one into an agent transcript is disclosure even
|
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
# * no modified or staged TRACKED files (real work is never deleted)
|
|
17
17
|
# * its HEAD commit is reachable from some remote ref (nothing unpushed)
|
|
18
18
|
# * its directory mtime is older than LISA_WORKTREE_MAX_AGE_DAYS (default 7)
|
|
19
|
+
# * lisa-worktree-guard reports no uncommitted bytes that exist in no commit
|
|
19
20
|
# Untracked-only dirt does NOT block removal — that junk is exactly what
|
|
20
|
-
# defeats the built-in sweep.
|
|
21
|
+
# defeats the built-in sweep. But "untracked" and "junk" are not the same word:
|
|
22
|
+
# a brand-new source file an agent has written and not yet committed is
|
|
23
|
+
# untracked too, and this sweep would delete it (CodySwannGT/lisa#3863). The
|
|
24
|
+
# guard draws the line by CONTENT rather than by tracking state — a file whose
|
|
25
|
+
# exact bytes are already in some commit is free to delete, one whose bytes
|
|
26
|
+
# exist nowhere else is not, and ignored paths (node_modules, build output)
|
|
27
|
+
# never reach the question. Set LISA_WORKTREE_CLEANUP=off to disable.
|
|
21
28
|
|
|
22
29
|
set -u
|
|
23
30
|
|
|
@@ -37,6 +44,33 @@ esac
|
|
|
37
44
|
wt_root="$repo_root/.claude/worktrees"
|
|
38
45
|
[ -d "$wt_root" ] || exit 0
|
|
39
46
|
|
|
47
|
+
# Resolve the content-reachability guard once. Three homes, in the order a host
|
|
48
|
+
# project actually has them: the applied copy, the installed package, and Lisa's
|
|
49
|
+
# own delivery tree (Lisa does not apply this script to itself).
|
|
50
|
+
guard=""
|
|
51
|
+
for candidate in \
|
|
52
|
+
"$repo_root/scripts/lisa-worktree-guard.mjs" \
|
|
53
|
+
"$repo_root/node_modules/@codyswann/lisa/all/copy-overwrite/scripts/lisa-worktree-guard.mjs" \
|
|
54
|
+
"$repo_root/all/copy-overwrite/scripts/lisa-worktree-guard.mjs"; do
|
|
55
|
+
if [ -f "$candidate" ]; then
|
|
56
|
+
guard="$candidate"
|
|
57
|
+
break
|
|
58
|
+
fi
|
|
59
|
+
done
|
|
60
|
+
command -v node > /dev/null 2>&1 || guard=""
|
|
61
|
+
|
|
62
|
+
# Whether a worktree's uncommitted content is safe to destroy. With the guard
|
|
63
|
+
# present the question is answered by content; without it (no node, no delivered
|
|
64
|
+
# script) the sweep falls back to the conservative shape — ANY non-ignored
|
|
65
|
+
# untracked file keeps the tree. A missing guard must not read as consent.
|
|
66
|
+
removal_allowed() {
|
|
67
|
+
if [ -n "$guard" ]; then
|
|
68
|
+
node "$guard" check "$1" > /dev/null 2>&1
|
|
69
|
+
return $?
|
|
70
|
+
fi
|
|
71
|
+
[ -z "$(git -C "$1" ls-files --others --exclude-standard 2> /dev/null | head -1)" ]
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
now=$(date +%s)
|
|
41
75
|
max_age_secs=$((MAX_AGE_DAYS * 86400))
|
|
42
76
|
removed=0
|
|
@@ -66,6 +100,13 @@ for wt in "$wt_root"/*/; do
|
|
|
66
100
|
sha=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || continue
|
|
67
101
|
[ -n "$(git -C "$wt" branch -r --contains "$sha" 2>/dev/null | head -1)" ] || continue
|
|
68
102
|
|
|
103
|
+
# Content gate: refuse to destroy bytes that live in no commit. The
|
|
104
|
+
# tracked-file check above answers a different question — a file an agent
|
|
105
|
+
# has written but never `git add`ed is untracked, so that check reports the
|
|
106
|
+
# tree clean while the whole deliverable sits in it. Only commits are shared
|
|
107
|
+
# between worktrees; this directory's working files and index are not.
|
|
108
|
+
removal_allowed "$wt" || continue
|
|
109
|
+
|
|
69
110
|
# git worktree lock (held during live agent execution) blocks removal;
|
|
70
111
|
# --force only clears untracked junk, never the gates above.
|
|
71
112
|
git -C "$repo_root" worktree remove --force "$wt" 2>/dev/null && removed=$((removed + 1))
|
|
@@ -2030,6 +2030,77 @@ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$
|
|
|
2030
2030
|
# that could not run produced no lines, the loop body never executed, and the
|
|
2031
2031
|
# guard was skipped with the command ALLOWED. A segmentation that cannot answer
|
|
2032
2032
|
# must refuse, exactly like a scan that cannot answer.
|
|
2033
|
+
# 14b. Linked worktrees, which are the same principle one level out. `.git` is
|
|
2034
|
+
# protected because nothing in the working tree can rebuild it; a linked
|
|
2035
|
+
# worktree is protected because nothing OUTSIDE it can rebuild what it holds.
|
|
2036
|
+
# The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
|
|
2037
|
+
# the object store are shared by every worktree of a repository, while
|
|
2038
|
+
# `.git/index` and the working files are per-worktree. So `git commit` moves
|
|
2039
|
+
# work somewhere a deletion cannot reach, and `git add` does not — a staged file
|
|
2040
|
+
# shows green in `git status`, which reads as saved, and dies with the
|
|
2041
|
+
# directory.
|
|
2042
|
+
#
|
|
2043
|
+
# Measured twice in one evening (issue #3863): one worktree lost a 284-line
|
|
2044
|
+
# staged test file, and a second lost nothing from the identical event purely
|
|
2045
|
+
# because a gate had forced its work into a commit first. Neither deletion
|
|
2046
|
+
# announced itself. That is the second reason this is a block rather than a
|
|
2047
|
+
# warning — a worktree deletion destroys the only evidence its own case could
|
|
2048
|
+
# leave, so there is no after-the-fact detection to fall back on.
|
|
2049
|
+
#
|
|
2050
|
+
# `git worktree remove` (guard 12) already refuses a dirty tree, and
|
|
2051
|
+
# `--force` is already blocked. This closes the spelling that walks around both:
|
|
2052
|
+
# `rm -rf` on the directory, which git never sees.
|
|
2053
|
+
#
|
|
2054
|
+
# Matching is by REGISTERED path — `git worktree list` names them — so an
|
|
2055
|
+
# ordinary directory that merely lives near one is untouched. A repository that
|
|
2056
|
+
# cannot be read answers "no": this guard adds protection to a shape that was
|
|
2057
|
+
# fully allowed a moment ago, and failing it closed would deny every recursive
|
|
2058
|
+
# delete on every path outside a repository.
|
|
2059
|
+
#
|
|
2060
|
+
# The comparison is on WHOLE TOKENS, which is the only spelling that gets both
|
|
2061
|
+
# error directions right. Substring containment would let a worktree named
|
|
2062
|
+
# `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
|
|
2063
|
+
# hand-written boundary classes leak the other way, because `rm -rf <wt>/`
|
|
2064
|
+
# with one trailing slash ends on a character every such class treats as
|
|
2065
|
+
# "inside a path". So every non-path character — quote, space, parenthesis,
|
|
2066
|
+
# redirection — is folded to a space first, which makes each argument a
|
|
2067
|
+
# space-delimited token regardless of how it was quoted, and the match is then
|
|
2068
|
+
# an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
|
|
2069
|
+
# deliberately not this guard's business: it is not a worktree deletion, and
|
|
2070
|
+
# blocking routine cleanup inside a worktree is how a guard gets switched off.
|
|
2071
|
+
names_linked_worktree() {
|
|
2072
|
+
local stmt="$1" probe root listed line primary wt rel
|
|
2073
|
+
root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
|
|
2074
|
+
[ -n "$root" ] || return 1
|
|
2075
|
+
listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
|
|
2076
|
+
probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
|
|
2077
|
+
primary=""
|
|
2078
|
+
while IFS= read -r line; do
|
|
2079
|
+
case "$line" in
|
|
2080
|
+
worktree\ *) wt="${line#worktree }" ;;
|
|
2081
|
+
*) continue ;;
|
|
2082
|
+
esac
|
|
2083
|
+
# First entry is the primary checkout, which is not a linked worktree.
|
|
2084
|
+
if [ -z "$primary" ]; then
|
|
2085
|
+
primary="$wt"
|
|
2086
|
+
continue
|
|
2087
|
+
fi
|
|
2088
|
+
case "$probe" in
|
|
2089
|
+
*" $wt "* | *" $wt/ "*) return 0 ;;
|
|
2090
|
+
esac
|
|
2091
|
+
rel="${wt#"$primary"/}"
|
|
2092
|
+
[ "$rel" != "$wt" ] || continue
|
|
2093
|
+
# `./` is the same path spelled the way an agent in the project root types
|
|
2094
|
+
# it, and a token comparison is exact, so it has to be spelled here too.
|
|
2095
|
+
case "$probe" in
|
|
2096
|
+
*" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
|
|
2097
|
+
esac
|
|
2098
|
+
done <<EOF
|
|
2099
|
+
$listed
|
|
2100
|
+
EOF
|
|
2101
|
+
return 1
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2033
2104
|
git_segments_status=0
|
|
2034
2105
|
git_segments="$(printf '%s' "$normalized_command_str" \
|
|
2035
2106
|
| awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
|
|
@@ -2052,6 +2123,9 @@ while IFS= read -r git_stmt; do
|
|
|
2052
2123
|
if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
|
|
2053
2124
|
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."
|
|
2054
2125
|
fi
|
|
2126
|
+
if names_linked_worktree "$git_stmt"; then
|
|
2127
|
+
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."
|
|
2128
|
+
fi
|
|
2055
2129
|
done <<<"$git_segments"
|
|
2056
2130
|
|
|
2057
2131
|
# 15. Credential stores. Reading one into an agent transcript is disclosure even
|