@codyswann/lisa 4.38.2 → 4.38.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -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 +17 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/hooks-installer.d.ts.map +1 -1
- package/dist/opencode/hooks-installer.js +1 -0
- package/dist/opencode/hooks-installer.js.map +1 -1
- package/dist/opencode/plugin-catalog.d.ts.map +1 -1
- package/dist/opencode/plugin-catalog.js +5 -0
- package/dist/opencode/plugin-catalog.js.map +1 -1
- package/dist/opencode/plugin-templates/block-managed-file-edits.sh +937 -0
- package/dist/opencode/plugin-templates/lisa-block-managed-file-edits.ts +83 -0
- package/package.json +6 -5
- package/plugins/lisa/.claude-plugin/plugin.json +9 -1
- package/plugins/lisa/.codex-plugin/hooks.json +8 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-managed-file-edits.agy.sh +63 -0
- package/plugins/lisa/hooks/threshold-ratchet-families.mjs +8 -0
- package/plugins/lisa-agy/hooks/block-managed-file-edits.agy.sh +63 -0
- package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +937 -0
- package/plugins/lisa-agy/hooks.json +13 -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 +9 -1
- package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +8 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +8 -0
- package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +8 -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/.claude-plugin/plugin.json +9 -1
- package/plugins/src/base/hooks/block-managed-file-edits.agy.sh +63 -0
- package/plugins/src/base/hooks/threshold-ratchet-families.mjs +8 -0
- package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +8 -0
- package/scripts/check-typecheck-tests.mjs +147 -0
- package/scripts/copy-opencode-plugin-templates.mjs +1 -0
- package/scripts/generate-agy-plugin-artifacts.mjs +12 -0
- package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +8 -0
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse hook: refuse agent writes to files Lisa overwrites on every apply.
|
|
3
|
+
#
|
|
4
|
+
# Lisa ships templates in three modes, and only one of them is unsafe to edit:
|
|
5
|
+
#
|
|
6
|
+
# copy-overwrite — see below; the harm depends on the file.
|
|
7
|
+
# copy-contents — Lisa APPENDS its lines; host content survives.
|
|
8
|
+
# create-only — skipped when the file exists; the host owns it outright.
|
|
9
|
+
#
|
|
10
|
+
# So this guard covers copy-overwrite and nothing else. Blocking the other two
|
|
11
|
+
# would stop agents editing files they are supposed to own, which is worse than
|
|
12
|
+
# the problem being solved.
|
|
13
|
+
#
|
|
14
|
+
# The name `copy-overwrite` is misleading, and two successive versions of this
|
|
15
|
+
# guard got the consequence wrong by trusting it. MEASURED, by mutating four
|
|
16
|
+
# files in a scratch project and running a real `lisa apply` against them:
|
|
17
|
+
#
|
|
18
|
+
# scripts/lisa-gates.mjs (ledger-tracked) → SURVIVED
|
|
19
|
+
# .lintstagedrc.json (untracked, JSON) → SURVIVED
|
|
20
|
+
# .prettierignore (untracked, text) → SURVIVED
|
|
21
|
+
# .yamllint (untracked, text) → SURVIVED
|
|
22
|
+
#
|
|
23
|
+
# Summary line: `Overwritten: 0 files` / `Out of date: 3 files (managed
|
|
24
|
+
# templates changed; NOT updated)`.
|
|
25
|
+
#
|
|
26
|
+
# copy-overwrite overwrites an UNMODIFIED copy — it refreshes. It does not
|
|
27
|
+
# overwrite a host-edited one, in any population tested. So the harm is the same
|
|
28
|
+
# for both, and it is not deletion:
|
|
29
|
+
#
|
|
30
|
+
# THE FILE SILENTLY FORKS. It keeps looking current while every upstream fix
|
|
31
|
+
# stops reaching it. Nothing is lost, which is exactly what makes it invisible.
|
|
32
|
+
#
|
|
33
|
+
# Ledger membership changes the MESSAGE apply prints, not the outcome — tracked
|
|
34
|
+
# files get a provenance verdict naming the fork and offering
|
|
35
|
+
# `lisa-guard-capabilities:`; untracked ones get a bare "Out of date" warning.
|
|
36
|
+
# The refusal branches on that so the reader sees the words apply will use.
|
|
37
|
+
#
|
|
38
|
+
# Measured, not hypothetical. Nothing enforced this, so downstream copies were
|
|
39
|
+
# edited and then silently diverged: `classify-maestro-failures.mjs` reached
|
|
40
|
+
# 36,061 bytes in one fleet repo against 29,586 shipped, and five gate files in
|
|
41
|
+
# another stopped receiving upstream fixes — one over roughly 138 bytes of
|
|
42
|
+
# cosmetic change. The edits were made in good faith; nothing told anyone the
|
|
43
|
+
# file was not theirs.
|
|
44
|
+
#
|
|
45
|
+
# ## Why the path, not a banner comment
|
|
46
|
+
#
|
|
47
|
+
# 103 of Lisa's 145 copy-overwrite files carry a "managed by Lisa" header, and
|
|
48
|
+
# the other 42 CANNOT: 30 are `.json` (no comment syntax), 8 are `.gitkeep` /
|
|
49
|
+
# `.keep` placeholders, and the rest are bare-value files like `.nvmrc`. A guard
|
|
50
|
+
# keyed on the banner would miss every one of them, so this resolves the path
|
|
51
|
+
# against the installed package instead and covers all 145 regardless of format.
|
|
52
|
+
#
|
|
53
|
+
# Blocked signatures:
|
|
54
|
+
# 1. Write / Edit / MultiEdit / NotebookEdit whose target resolves to a
|
|
55
|
+
# copy-overwrite template in the installed Lisa package;
|
|
56
|
+
# 2. Bash output redirection (`>`, `>>`, `>|`), `tee`, or an in-place `sed`
|
|
57
|
+
# aimed at one — `-i`, `-i ''`, `-i.bak`, a cluster such as `-ni`, and the
|
|
58
|
+
# long `--in-place[=SUFFIX]`. Reads never fire;
|
|
59
|
+
# 3. any of those inside a script the command EXECUTES — `bash edit.sh`,
|
|
60
|
+
# `sh`, `source`, `.`, `bash < edit.sh`, and behind wrappers that carry an
|
|
61
|
+
# operand of their own (`nice -n 5`, `timeout 5`, `sudo`).
|
|
62
|
+
#
|
|
63
|
+
# ## What signature 3 deliberately does NOT do
|
|
64
|
+
#
|
|
65
|
+
# A path is followed ONLY when the command EXECUTES it. `grep -n x edit.sh`,
|
|
66
|
+
# `wc -l edit.sh`, `git diff edit.sh` and a test run over it name a file as
|
|
67
|
+
# DATA, and following those is the known-wrong fix this guard family has already
|
|
68
|
+
# made once: it refuses ordinary reads and attributes a file's capability to a
|
|
69
|
+
# command that merely mentions it. Command POSITION decides. Taint does not
|
|
70
|
+
# propagate any further either — a path a followed script merely NAMES is data
|
|
71
|
+
# one file further out, not a third hop.
|
|
72
|
+
#
|
|
73
|
+
# ## Parity gap, recorded rather than silently dropped
|
|
74
|
+
#
|
|
75
|
+
# This guard has no Antigravity, Codex or OpenCode port — only the Claude
|
|
76
|
+
# reference and the copies generated from it. Its siblings do. That predates
|
|
77
|
+
# this change and is not closed by it: opening three ports is its own work, and
|
|
78
|
+
# doing it here would triple the surface under review. Recorded so it reads as a
|
|
79
|
+
# known gap rather than an oversight.
|
|
80
|
+
#
|
|
81
|
+
# Exemptions (allowed):
|
|
82
|
+
# - `LISA_ALLOW_MANAGED_FILE_WRITE` set — the operator's explicit override,
|
|
83
|
+
# named in the refusal;
|
|
84
|
+
# - paths under `node_modules/` or `dist/` — vendored copies, not the host's;
|
|
85
|
+
# - the Lisa source repository itself, where these files are the originals and
|
|
86
|
+
# editing them is the entire point.
|
|
87
|
+
#
|
|
88
|
+
# The line below lets `lisa apply` tell a downstream copy of this guard that is
|
|
89
|
+
# BEHIND from one that is AHEAD. Byte comparison cannot: both look like "differs
|
|
90
|
+
# from mine", and guessing "behind" is how a fork's stronger guard gets silently
|
|
91
|
+
# replaced by a weaker upstream one. Add a name here in the same commit that
|
|
92
|
+
# closes a vector.
|
|
93
|
+
# lisa-guard-capabilities: managed-path-resolution, lisaignore-precedence, generated-path-rebuild, redirect-target, tee-target, sed-in-place-all-spellings, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, direct-script-command-reach, analyzer-failure-visible, apply-patch-parse-failure-visible, apply-patch-move-target, shell-command-string-reach
|
|
94
|
+
set -euo pipefail
|
|
95
|
+
|
|
96
|
+
input="$(cat)"
|
|
97
|
+
|
|
98
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
99
|
+
|
|
100
|
+
# The operator's override, checked first so it is always the cheapest way out.
|
|
101
|
+
if [ -n "${LISA_ALLOW_MANAGED_FILE_WRITE:-}" ]; then
|
|
102
|
+
exit 0
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
|
|
106
|
+
[ -n "$tool_name" ] || exit 0
|
|
107
|
+
|
|
108
|
+
project_root="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
109
|
+
package_root="$project_root/node_modules/@codyswann/lisa"
|
|
110
|
+
|
|
111
|
+
# No installed Lisa means nothing to classify against. Silent, because a project
|
|
112
|
+
# that has not installed Lisa is not doing anything wrong.
|
|
113
|
+
[ -d "$package_root" ] || exit 0
|
|
114
|
+
|
|
115
|
+
# In Lisa's own repository these files ARE the originals. Blocking here would
|
|
116
|
+
# make the templates uneditable by the only agents that should edit them.
|
|
117
|
+
if [ -f "$project_root/package.json" ] &&
|
|
118
|
+
grep -q '"name": *"@codyswann/lisa"' "$project_root/package.json" 2>/dev/null; then
|
|
119
|
+
exit 0
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
# Whether the project has claimed a path in `.lisaignore`.
|
|
123
|
+
#
|
|
124
|
+
# THIS IS THE OWNERSHIP QUESTION, and it decides whether blocking is right at
|
|
125
|
+
# all. `lisa apply` skips an ignored path — it logs `Kept (.lisaignore)` and
|
|
126
|
+
# counts it as ignored rather than overwritten — so the file is the project's,
|
|
127
|
+
# its edits survive, and refusing them would be refusing someone access to their
|
|
128
|
+
# own file. `doctor-lisa-owned-artifacts` already consults this list; this guard
|
|
129
|
+
# did not, which would have blocked deliberate forks.
|
|
130
|
+
#
|
|
131
|
+
# The real matcher is minimatch in `src/utils/ignore-patterns.ts` and cannot be
|
|
132
|
+
# reproduced faithfully in shell. This covers the common shapes — exact path,
|
|
133
|
+
# directory prefix, glob, and a bare pattern matching any segment — and where it
|
|
134
|
+
# is unsure it ALLOWS. Wrongly allowing costs an edit that apply may overwrite,
|
|
135
|
+
# which is the behaviour before this guard existed; wrongly blocking locks
|
|
136
|
+
# someone out of a file they own.
|
|
137
|
+
lisaignored() {
|
|
138
|
+
local rel="$1"
|
|
139
|
+
local list="$project_root/.lisaignore"
|
|
140
|
+
[ -f "$list" ] || return 1
|
|
141
|
+
# Gitignore precedence: patterns are read in order and the LAST one to select
|
|
142
|
+
# the path decides, so `!x` re-includes something an earlier line ignored.
|
|
143
|
+
# This mirrors `matchesAnyPattern` in `src/utils/ignore-patterns.ts`; the two
|
|
144
|
+
# must agree, or an agent gets blocked on a file apply considers the
|
|
145
|
+
# project's, or waved through on one it does not.
|
|
146
|
+
#
|
|
147
|
+
# `ignored` carries shell truth: 0 = ignored, 1 = not.
|
|
148
|
+
local ignored=1
|
|
149
|
+
local pattern
|
|
150
|
+
while IFS= read -r pattern || [ -n "$pattern" ]; do
|
|
151
|
+
pattern="${pattern#"${pattern%%[![:space:]]*}"}"
|
|
152
|
+
pattern="${pattern%"${pattern##*[![:space:]]}"}"
|
|
153
|
+
[ -n "$pattern" ] || continue
|
|
154
|
+
case "$pattern" in \#*) continue ;; esac
|
|
155
|
+
local negated=1
|
|
156
|
+
case "$pattern" in
|
|
157
|
+
# A bare `!` selects nothing rather than everything.
|
|
158
|
+
!) continue ;;
|
|
159
|
+
!*)
|
|
160
|
+
negated=0
|
|
161
|
+
pattern="${pattern#!}"
|
|
162
|
+
;;
|
|
163
|
+
# `\!x` is a literal leading `!`, not a negation.
|
|
164
|
+
\\!*) pattern="${pattern#\\}" ;;
|
|
165
|
+
esac
|
|
166
|
+
local selected=1
|
|
167
|
+
case "$pattern" in
|
|
168
|
+
*/)
|
|
169
|
+
case "$rel" in "${pattern%/}"/* | "${pattern%/}") selected=0 ;; esac
|
|
170
|
+
;;
|
|
171
|
+
esac
|
|
172
|
+
if [ "$selected" -ne 0 ]; then
|
|
173
|
+
# The pattern is a glob on purpose, so it is deliberately unquoted.
|
|
174
|
+
#
|
|
175
|
+
# The reason sits on its own line because a shellcheck directive is
|
|
176
|
+
# `key=value` pairs and NOTHING else. Written as
|
|
177
|
+
# `disable=SC2254 -- <prose>`, the trailing words are read as more
|
|
178
|
+
# directive keys: the directive fails with SC1072/SC1073, shellcheck
|
|
179
|
+
# stops checking the rest of THIS FILE, and SC2254 is left unsuppressed
|
|
180
|
+
# as well. Measured with shellcheck 0.11.0. Do not rejoin these lines.
|
|
181
|
+
# shellcheck disable=SC2254
|
|
182
|
+
case "$rel" in $pattern) selected=0 ;; esac
|
|
183
|
+
fi
|
|
184
|
+
if [ "$selected" -ne 0 ]; then
|
|
185
|
+
case "$pattern" in
|
|
186
|
+
*/*) ;;
|
|
187
|
+
*)
|
|
188
|
+
# Ditto, matched against the basename — and ditto about the reason
|
|
189
|
+
# living on its own line rather than after the directive.
|
|
190
|
+
# shellcheck disable=SC2254
|
|
191
|
+
case "${rel##*/}" in $pattern) selected=0 ;; esac
|
|
192
|
+
;;
|
|
193
|
+
esac
|
|
194
|
+
fi
|
|
195
|
+
[ "$selected" -eq 0 ] || continue
|
|
196
|
+
# A positive match ignores the path; a negated one un-ignores it. Both
|
|
197
|
+
# OVERWRITE any earlier verdict rather than short-circuiting — that is what
|
|
198
|
+
# makes the last matching pattern win.
|
|
199
|
+
if [ "$negated" -eq 0 ]; then
|
|
200
|
+
ignored=1
|
|
201
|
+
else
|
|
202
|
+
ignored=0
|
|
203
|
+
fi
|
|
204
|
+
done <"$list"
|
|
205
|
+
return "$ignored"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# A candidate rewritten relative to the project, so an absolute target from a
|
|
209
|
+
# tool payload and a relative one from a shell command classify identically.
|
|
210
|
+
relative_path() {
|
|
211
|
+
local rel="${1#"$project_root"/}"
|
|
212
|
+
printf '%s' "${rel#./}"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# Whether a host-relative path is shipped as a copy-overwrite template.
|
|
216
|
+
#
|
|
217
|
+
# A few stat calls rather than an enumeration: the same relative path is probed
|
|
218
|
+
# under each stack's copy-overwrite tree, so cost does not grow with the 145
|
|
219
|
+
# templates.
|
|
220
|
+
managed_source() {
|
|
221
|
+
local candidate="$1"
|
|
222
|
+
case "$candidate" in
|
|
223
|
+
*/node_modules/* | node_modules/* | */dist/* | dist/*) return 1 ;;
|
|
224
|
+
esac
|
|
225
|
+
local rel
|
|
226
|
+
rel="$(relative_path "$candidate")"
|
|
227
|
+
[ -n "$rel" ] || return 1
|
|
228
|
+
# Claimed by the project, so not ours to refuse.
|
|
229
|
+
lisaignored "$rel" && return 1
|
|
230
|
+
local stack
|
|
231
|
+
for stack in "$package_root"/*/copy-overwrite; do
|
|
232
|
+
[ -d "$stack" ] || continue
|
|
233
|
+
if [ -e "$stack/$rel" ]; then
|
|
234
|
+
printf '%s' "${stack#"$package_root"/}/$rel"
|
|
235
|
+
return 0
|
|
236
|
+
fi
|
|
237
|
+
done
|
|
238
|
+
# A generated path has no template to point at — apply rebuilds it from the
|
|
239
|
+
# migration rather than copying it — so the source is named for the reader
|
|
240
|
+
# rather than resolved to a file that does not exist.
|
|
241
|
+
if generated_path "$rel"; then
|
|
242
|
+
printf '%s' "generated by lisa apply"
|
|
243
|
+
return 0
|
|
244
|
+
fi
|
|
245
|
+
return 1
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Whether a destination is a path `lisa apply` GENERATES rather than copies.
|
|
249
|
+
#
|
|
250
|
+
# Read out of the installed package, never restated here.
|
|
251
|
+
# `dist/migrations/generated-paths.js` is the single source the vendoring
|
|
252
|
+
# migration also imports, so a new generated tree is covered without editing this
|
|
253
|
+
# hook. A hook carrying its own copy of that list would be a second place to
|
|
254
|
+
# update that nobody updates, which then governs silently — the defect this file
|
|
255
|
+
# already documents for copy-overwrite, one step along.
|
|
256
|
+
#
|
|
257
|
+
# The distinction earns its keep because the two populations fail in OPPOSITE
|
|
258
|
+
# ways, and a guard that gives one answer is wrong for half the files it covers:
|
|
259
|
+
# an edited copy-overwrite file is PRESERVED and silently forks, while a
|
|
260
|
+
# generated file is REBUILT and the edit vanishes on the next install.
|
|
261
|
+
generated_path() {
|
|
262
|
+
local rel="$1"
|
|
263
|
+
local module="$package_root/dist/migrations/generated-paths.js"
|
|
264
|
+
[ -f "$module" ] || return 1
|
|
265
|
+
local prefixes
|
|
266
|
+
prefixes="$(grep -oE '"[^"]+"' "$module" 2>/dev/null | tr -d '"')"
|
|
267
|
+
local prefix
|
|
268
|
+
for prefix in $prefixes; do
|
|
269
|
+
case "$prefix" in
|
|
270
|
+
*/*) ;;
|
|
271
|
+
*) continue ;;
|
|
272
|
+
esac
|
|
273
|
+
case "$rel" in
|
|
274
|
+
"$prefix" | "$prefix"/*) return 0 ;;
|
|
275
|
+
esac
|
|
276
|
+
done
|
|
277
|
+
return 1
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# Whether a destination is a ledger-tracked Lisa-owned guard.
|
|
281
|
+
#
|
|
282
|
+
# Both populations are PRESERVED once edited (measured — see the header). What
|
|
283
|
+
# membership changes is what apply prints and what the escape hatch is: a tracked
|
|
284
|
+
# guard gets a provenance verdict and can declare `lisa-guard-capabilities:`,
|
|
285
|
+
# while an untracked template gets a bare "Out of date" line and `.lisaignore`.
|
|
286
|
+
# The refusal quotes the words the reader will actually see, so it has to know
|
|
287
|
+
# which side it is on.
|
|
288
|
+
ledger_tracked() {
|
|
289
|
+
local rel="$1"
|
|
290
|
+
local ledger="$package_root/dist/core/lisa-owned-hash-ledger.js"
|
|
291
|
+
[ -f "$ledger" ] || return 1
|
|
292
|
+
grep -q "\"$rel\"" "$ledger" 2>/dev/null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
refuse() {
|
|
296
|
+
local target="$1"
|
|
297
|
+
local source="$2"
|
|
298
|
+
local rel="$3"
|
|
299
|
+
local consequence
|
|
300
|
+
if generated_path "$rel"; then
|
|
301
|
+
consequence="\`lisa apply\` REGENERATES this file. It is not copied from a
|
|
302
|
+
template and it is not preserved: the next \`bun install\` rebuilds it and your
|
|
303
|
+
edit is gone, with nothing reporting that it had been.
|
|
304
|
+
|
|
305
|
+
That is the opposite failure from the copy-overwrite files this guard also
|
|
306
|
+
covers, where an edit SURVIVES and forks silently. Here the edit works locally,
|
|
307
|
+
CI agrees because CI regenerates too, and it disappears on the next install."
|
|
308
|
+
elif ledger_tracked "$rel"; then
|
|
309
|
+
consequence="\`lisa apply\` will KEEP your edit — this is a Lisa-owned guard,
|
|
310
|
+
and apply says so: \"its contents match no Lisa release, so Lisa cannot tell
|
|
311
|
+
whether it is out of date or deliberately stronger. Kept yours.\"
|
|
312
|
+
|
|
313
|
+
That is the trap. Nothing is deleted; the file silently FORKS. It keeps looking
|
|
314
|
+
current while every upstream fix stops reaching it. One repository in this fleet
|
|
315
|
+
carries 243 lines of divergence nobody knew about, in a guard that had quietly
|
|
316
|
+
stopped receiving fixes."
|
|
317
|
+
else
|
|
318
|
+
consequence="\`lisa apply\` will KEEP your edit and report the file as
|
|
319
|
+
\"Out of date, not updated\" on every run from now on.
|
|
320
|
+
|
|
321
|
+
That is the trap. Nothing is deleted; the file silently FORKS, stops receiving
|
|
322
|
+
upstream changes, and adds a permanent warning line that the next person learns
|
|
323
|
+
to scroll past."
|
|
324
|
+
fi
|
|
325
|
+
cat >&2 <<EOF
|
|
326
|
+
BLOCKED: refusing to write \`$target\`.
|
|
327
|
+
|
|
328
|
+
WHY: this file is Lisa-managed, shipped as a **copy-overwrite** template
|
|
329
|
+
(\`$source\` in the installed package).
|
|
330
|
+
|
|
331
|
+
$consequence
|
|
332
|
+
|
|
333
|
+
WHERE IT GOES INSTEAD — take the first one that fits:
|
|
334
|
+
|
|
335
|
+
1. The change should apply everywhere. Edit the template upstream in Lisa and
|
|
336
|
+
release it — \`/lisa:cross-pollinate\`, or an issue on CodySwannGT/lisa.
|
|
337
|
+
That is the only edit that survives an install.
|
|
338
|
+
|
|
339
|
+
2. Only this project needs to differ. Look for the local escape hatch beside the
|
|
340
|
+
file — Lisa ships \`.local\` variants for the configs that support one
|
|
341
|
+
(\`eslint.config.local.ts\`, \`tsconfig.local.json\`, \`audit.ignore.local.json\`,
|
|
342
|
+
and others). Those are yours and are never overwritten.
|
|
343
|
+
|
|
344
|
+
3. The behaviour is configurable rather than hardcoded. Check \`.lisa.config.json\`
|
|
345
|
+
— the \`gates\` block in particular decides which checks run and what command
|
|
346
|
+
proves each one, so a project can substitute its own task without touching a
|
|
347
|
+
shipped file.
|
|
348
|
+
|
|
349
|
+
4. This project has deliberately FORKED this file and means to keep its own
|
|
350
|
+
version. Apply already preserves the edit either way, so what is left to
|
|
351
|
+
choose is whether the fork stays VISIBLE. Keeping it visible is the point:
|
|
352
|
+
|
|
353
|
+
- **A Lisa-owned guard (hash-tracked).** Do NOT add it to \`.lisaignore\`.
|
|
354
|
+
Apply preserves your version regardless, so ignoring it buys nothing — and
|
|
355
|
+
it silences the standoff \`lisa doctor\` reports on every run, replacing a
|
|
356
|
+
true warning with the line "Enforcement guards match the installed Lisa
|
|
357
|
+
version", which is then false. A visible, resolvable fork becomes a silent
|
|
358
|
+
permanent one. Instead declare what your version defends with a
|
|
359
|
+
\`lisa-guard-capabilities:\` line; apply then classifies it \`host-ahead\`
|
|
360
|
+
and says so by name, rather than reporting that it cannot tell.
|
|
361
|
+
- **Any other template.** \`.lisaignore\` records the divergence where the
|
|
362
|
+
next person can see it and stops the recurring "Out of date" line. It does
|
|
363
|
+
not preserve the file — apply already does — so use it to DECLARE a fork
|
|
364
|
+
you have decided on, never to quiet one you have not.
|
|
365
|
+
|
|
366
|
+
5. You believe this file should not be Lisa-managed at all. That is a real
|
|
367
|
+
argument and it belongs upstream, not in a local edit that will be erased.
|
|
368
|
+
|
|
369
|
+
If a human explicitly asked for this edit, they can re-run with
|
|
370
|
+
\`LISA_ALLOW_MANAGED_FILE_WRITE=1\` set, which bypasses this guard.
|
|
371
|
+
EOF
|
|
372
|
+
exit 2
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case "$tool_name" in
|
|
376
|
+
apply_patch)
|
|
377
|
+
# Codex's primary edit mechanism, and the one this guard was already
|
|
378
|
+
# registered for while being unable to read it.
|
|
379
|
+
#
|
|
380
|
+
# `src/codex/enforcement-fallback-installer.ts` registers the dispatcher
|
|
381
|
+
# on `Bash|Edit|Write|apply_patch`, so this envelope was already being
|
|
382
|
+
# delivered here - and matched no arm below, fell off the end of the
|
|
383
|
+
# `case`, and exited 0. A registered guard that receives the call and
|
|
384
|
+
# allows it is worse than an unregistered one, because the wiring reads
|
|
385
|
+
# as complete: an auditor sees the matcher forwarding `apply_patch` into
|
|
386
|
+
# a guard about managed files and correctly concludes it is covered
|
|
387
|
+
# (CodySwannGT/lisa#3776).
|
|
388
|
+
#
|
|
389
|
+
# The envelope carries no `file_path`. Verified against codex-cli 0.125.0
|
|
390
|
+
# by capturing real hook stdin - see `src/codex/scripts/_extract-edit-paths.sh`,
|
|
391
|
+
# which performs this same header parse for the Codex-side hooks:
|
|
392
|
+
#
|
|
393
|
+
# Edit / Write -> tool_input.file_path (single string)
|
|
394
|
+
# apply_patch -> tool_input.command (a STRING holding the whole
|
|
395
|
+
# patch, NOT an array)
|
|
396
|
+
#
|
|
397
|
+
# and the patch names its targets in header lines, MANY per patch:
|
|
398
|
+
#
|
|
399
|
+
# *** Add File: <path>
|
|
400
|
+
# *** Update File: <path>
|
|
401
|
+
# *** Delete File: <path>
|
|
402
|
+
# *** Move to: <path>
|
|
403
|
+
#
|
|
404
|
+
# So every header is walked and each target classified; one managed
|
|
405
|
+
# target anywhere in a multi-file patch refuses the whole call, because
|
|
406
|
+
# the patch applies atomically and there is no partial acceptance to
|
|
407
|
+
# offer.
|
|
408
|
+
#
|
|
409
|
+
# This restates `_extract-edit-paths.sh`'s header match rather than
|
|
410
|
+
# sourcing it, and that is forced by where the two files ship: this guard
|
|
411
|
+
# installs to a host's `scripts/lisa-hooks/`, that helper to
|
|
412
|
+
# `.codex/hooks/lisa/`, and neither tree can source across to the other.
|
|
413
|
+
# `tests/unit/hooks/block-managed-file-edits-apply-patch.test.ts` pins the
|
|
414
|
+
# two header sets equal so the restatement cannot drift into a gap - the
|
|
415
|
+
# same answer `core/hook-copy-parity` gives for sibling copies that
|
|
416
|
+
# legitimately cannot be one file. What is NOT restated is the
|
|
417
|
+
# classification: `managed_source` and `refuse` are the single copy here,
|
|
418
|
+
# exactly as for every other arm.
|
|
419
|
+
if ! patch_text="$(printf '%s' "$input" | jq -er '
|
|
420
|
+
if ((.tool_input.command? // "") | type) == "string"
|
|
421
|
+
then (.tool_input.command? // "")
|
|
422
|
+
else error("tool_input.command must be a string")
|
|
423
|
+
end')"; then
|
|
424
|
+
printf 'block-managed-file-edits: could not parse the apply_patch command; managed-file protection refused the edit\n' >&2
|
|
425
|
+
exit 2
|
|
426
|
+
fi
|
|
427
|
+
[ -n "$patch_text" ] || exit 0
|
|
428
|
+
while IFS= read -r patch_line; do
|
|
429
|
+
case "$patch_line" in
|
|
430
|
+
"*** Add File: "* | "*** Update File: "* | "*** Delete File: "*)
|
|
431
|
+
candidate="${patch_line#*File: }"
|
|
432
|
+
;;
|
|
433
|
+
"*** Move to: "*)
|
|
434
|
+
candidate="${patch_line#*** Move to: }"
|
|
435
|
+
;;
|
|
436
|
+
*) continue ;;
|
|
437
|
+
esac
|
|
438
|
+
[ -n "$candidate" ] || continue
|
|
439
|
+
if source_path="$(managed_source "$candidate")"; then
|
|
440
|
+
refuse "$candidate" "$source_path" "$(relative_path "$candidate")"
|
|
441
|
+
fi
|
|
442
|
+
done <<EOF
|
|
443
|
+
$patch_text
|
|
444
|
+
EOF
|
|
445
|
+
;;
|
|
446
|
+
Write | Edit | MultiEdit | NotebookEdit | Update)
|
|
447
|
+
paths="$(printf '%s' "$input" | jq -r '
|
|
448
|
+
[ .tool_input.file_path?,
|
|
449
|
+
.tool_input.path?,
|
|
450
|
+
.tool_input.notebook_path?,
|
|
451
|
+
(.tool_input.edits? // [] | .[].file_path?)
|
|
452
|
+
] | map(select(. != null and . != "")) | .[]' 2>/dev/null || true)"
|
|
453
|
+
while IFS= read -r candidate; do
|
|
454
|
+
[ -n "$candidate" ] || continue
|
|
455
|
+
if source_path="$(managed_source "$candidate")"; then
|
|
456
|
+
refuse "$candidate" "$source_path" "$(relative_path "$candidate")"
|
|
457
|
+
fi
|
|
458
|
+
done <<EOF
|
|
459
|
+
$paths
|
|
460
|
+
EOF
|
|
461
|
+
;;
|
|
462
|
+
Bash)
|
|
463
|
+
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
|
|
464
|
+
[ -n "$command_str" ] || exit 0
|
|
465
|
+
# A missing interpreter must be announced rather than swallowed. This guard
|
|
466
|
+
# degrades to "allow" — a hook that cannot parse its input cannot tell a
|
|
467
|
+
# write from a read — but a guard that is silently absent reads exactly like
|
|
468
|
+
# a guard that is passing.
|
|
469
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
470
|
+
printf 'block-managed-file-edits: python3 not found; Bash write protection is NOT active\n' >&2
|
|
471
|
+
exit 0
|
|
472
|
+
fi
|
|
473
|
+
# Why a tokenizer replaced the two-stage regex.
|
|
474
|
+
#
|
|
475
|
+
# The old extraction was `grep -Eo` for a redirect / tee / in-place sed,
|
|
476
|
+
# followed by a `sed -E` strip of the matched prefix. MEASURED against it:
|
|
477
|
+
# `tee <path>` and `tee -a <path>` extracted correctly, and EVERY `sed -i`
|
|
478
|
+
# spelling extracted NOTHING — `-i`, `-i ''` and `-i.bak` alike. The
|
|
479
|
+
# `grep` matched; the strip pattern `sed[[:space:]]+[^|;&]*-i[^|;&]*` is
|
|
480
|
+
# greedy, so it consumed the path along with the flags and left an empty
|
|
481
|
+
# token. The arm was DEAD, not merely missing macOS's two-token spelling.
|
|
482
|
+
#
|
|
483
|
+
# A regex cannot answer the question this guard actually asks — is this path
|
|
484
|
+
# WRITTEN or merely NAMED — because that depends on token position, which is
|
|
485
|
+
# what a tokenizer is for.
|
|
486
|
+
#
|
|
487
|
+
# The analyzer is read into a variable by `read -d ''` rather than run
|
|
488
|
+
# inside a command substitution wrapping a here-document. Bash finds the
|
|
489
|
+
# closing paren of a command substitution by re-scanning its text, and a
|
|
490
|
+
# here-document body nested there is scanned too: the lone backtick in
|
|
491
|
+
# `re.compile(r"[$`]")` below then reads as an unterminated backtick
|
|
492
|
+
# substitution and the whole script fails to parse. Keeping the
|
|
493
|
+
# here-document out of the substitution is the fix. This is also the shape
|
|
494
|
+
# `block-direct-issue-create.sh` already uses for its own classifier.
|
|
495
|
+
#
|
|
496
|
+
# The delimiter is deliberately not spelled out in this comment: a tool that
|
|
497
|
+
# greps for here-document starts would otherwise match the prose and read
|
|
498
|
+
# the wrong span.
|
|
499
|
+
analyzer=''
|
|
500
|
+
IFS= read -r -d '' analyzer <<'PY' || true
|
|
501
|
+
import os
|
|
502
|
+
import re
|
|
503
|
+
import shlex
|
|
504
|
+
|
|
505
|
+
command = os.environ.get("MANAGED_EDIT_COMMAND", "")
|
|
506
|
+
project = os.environ.get("MANAGED_EDIT_PROJECT", "") or os.getcwd()
|
|
507
|
+
|
|
508
|
+
# Following a script costs a read on every intercepted command, so the walk is
|
|
509
|
+
# bounded three ways, and a file past the cap is skipped rather than half-read:
|
|
510
|
+
# a truncated scan reports a confident ALLOW about text it never saw.
|
|
511
|
+
FOLLOW_MAX_BYTES = 262144
|
|
512
|
+
FOLLOW_MAX_FILES = 8
|
|
513
|
+
FOLLOW_MAX_DEPTH = 3
|
|
514
|
+
|
|
515
|
+
# Spelled with chr() rather than backslash escapes on purpose. This source is
|
|
516
|
+
# embedded in a shell heredoc and copied verbatim through several generators
|
|
517
|
+
# into every shipped plugin; a backslash literal is the one thing in it most
|
|
518
|
+
# likely to be mangled in transit, and the mangling would be silent.
|
|
519
|
+
BACKSLASH = chr(92)
|
|
520
|
+
NEWLINE = chr(10)
|
|
521
|
+
|
|
522
|
+
STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
|
|
523
|
+
# `>`, `>>`, and the noclobber override `>|`.
|
|
524
|
+
REDIRECT_OUT = {">", ">>", ">|"}
|
|
525
|
+
REDIRECT_IN = "<"
|
|
526
|
+
|
|
527
|
+
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
528
|
+
SOURCE_BUILTINS = {"source", "."}
|
|
529
|
+
|
|
530
|
+
# Wrappers that run what FOLLOWS them without changing what it is, mapped to
|
|
531
|
+
# (options whose value is a SEPARATE token, positional operands consumed).
|
|
532
|
+
# The positional count is why this is a table rather than a set: `timeout 5
|
|
533
|
+
# bash x` puts an operand between the wrapper and the interpreter, so a walk
|
|
534
|
+
# that steps over `-flags` only stops at `5` and never reaches `bash`.
|
|
535
|
+
FOLLOW_WRAPPERS = {
|
|
536
|
+
"builtin": (frozenset(), 0),
|
|
537
|
+
"command": (frozenset(), 0),
|
|
538
|
+
"exec": (frozenset({"-a"}), 0),
|
|
539
|
+
"nice": (frozenset({"-n", "--adjustment"}), 0),
|
|
540
|
+
"nohup": (frozenset(), 0),
|
|
541
|
+
"setsid": (frozenset(), 0),
|
|
542
|
+
"stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
|
|
543
|
+
"sudo": (
|
|
544
|
+
frozenset(
|
|
545
|
+
{
|
|
546
|
+
"-C", "--close-from", "-g", "--group", "-h", "--host",
|
|
547
|
+
"-p", "--prompt", "-r", "--role", "-t", "--type",
|
|
548
|
+
"-U", "--other-user", "-u", "--user",
|
|
549
|
+
}
|
|
550
|
+
),
|
|
551
|
+
0,
|
|
552
|
+
),
|
|
553
|
+
"time": (frozenset(), 0),
|
|
554
|
+
"timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
# `-i`, `-i.bak`, a cluster such as `-ni`, and the long `--in-place[=SUFFIX]`.
|
|
558
|
+
IN_PLACE = re.compile(r"^(--in-place(=.*)?|-[a-zA-Z]*i.*)$")
|
|
559
|
+
# A value the SHELL computes; this hook runs before expansion and cannot know
|
|
560
|
+
# which file it will name.
|
|
561
|
+
COMPUTED_VALUE = re.compile(r"[$`]")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def normalise_lines(text):
|
|
565
|
+
"""Spell line breaks as statement separators.
|
|
566
|
+
|
|
567
|
+
A newline ends a command exactly as `;` does, but shlex treats it as plain
|
|
568
|
+
whitespace — so two consecutive lines of a followed script would read as
|
|
569
|
+
one invocation and only the first program would be classified. A
|
|
570
|
+
backslash-newline is joined first, because there the newline is NOT a
|
|
571
|
+
boundary.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
text: A command line or a script's contents.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
The same text with line breaks spelled as separators.
|
|
578
|
+
"""
|
|
579
|
+
joined = text.replace(BACKSLASH + NEWLINE, " ")
|
|
580
|
+
return joined.replace(NEWLINE, " ; ")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def tokenize(text):
|
|
584
|
+
"""Tokenize with shell operators kept as their own tokens.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
text: A command line or a script's contents.
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
The token list, or None when the text does not lex.
|
|
591
|
+
"""
|
|
592
|
+
lexer = shlex.shlex(normalise_lines(text), posix=True, punctuation_chars=True)
|
|
593
|
+
lexer.whitespace_split = True
|
|
594
|
+
# `#` is not a comment introducer on a typed command line, and letting shlex
|
|
595
|
+
# treat it as one silently truncates the rest of the line.
|
|
596
|
+
lexer.commenters = ""
|
|
597
|
+
try:
|
|
598
|
+
return list(lexer)
|
|
599
|
+
except ValueError:
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def strip_comments(text):
|
|
604
|
+
"""Drop `#` comments from a FOLLOWED SCRIPT, quote-aware.
|
|
605
|
+
|
|
606
|
+
Applied to file contents and never to a typed command. A comment cannot
|
|
607
|
+
execute, and Lisa's own managed templates discuss redirection and `sed -i`
|
|
608
|
+
against managed paths in their headers — classifying a followed file
|
|
609
|
+
without dropping its comments reads documentation as an edit.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
text: A script's contents.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
The same text with unquoted comments removed.
|
|
616
|
+
"""
|
|
617
|
+
output = []
|
|
618
|
+
for line in text.split(NEWLINE):
|
|
619
|
+
quote = ""
|
|
620
|
+
escaped = False
|
|
621
|
+
cut = None
|
|
622
|
+
for position, character in enumerate(line):
|
|
623
|
+
if escaped:
|
|
624
|
+
escaped = False
|
|
625
|
+
continue
|
|
626
|
+
if character == BACKSLASH and quote != "'":
|
|
627
|
+
escaped = True
|
|
628
|
+
continue
|
|
629
|
+
if quote:
|
|
630
|
+
if character == quote:
|
|
631
|
+
quote = ""
|
|
632
|
+
continue
|
|
633
|
+
if character in "'\"":
|
|
634
|
+
quote = character
|
|
635
|
+
continue
|
|
636
|
+
# A `#` opens a comment only at the start of a word, so `x#y` and
|
|
637
|
+
# `${x#y}` keep their hashes.
|
|
638
|
+
if character == "#" and (position == 0 or line[position - 1].isspace()):
|
|
639
|
+
cut = position
|
|
640
|
+
break
|
|
641
|
+
output.append(line if cut is None else line[:cut])
|
|
642
|
+
return NEWLINE.join(output)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def statements(tokens):
|
|
646
|
+
"""Split a token list at statement separators, keeping redirects inline.
|
|
647
|
+
|
|
648
|
+
Redirect operators are deliberately NOT separators here: `>` and its target
|
|
649
|
+
have to stay in the same group, because the pair is the write signature.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
tokens: The operator-aware token list.
|
|
653
|
+
|
|
654
|
+
Returns:
|
|
655
|
+
A list of statements.
|
|
656
|
+
"""
|
|
657
|
+
grouped = []
|
|
658
|
+
current = []
|
|
659
|
+
for token in tokens:
|
|
660
|
+
if token in STATEMENT_SEPARATORS:
|
|
661
|
+
grouped.append(current)
|
|
662
|
+
current = []
|
|
663
|
+
continue
|
|
664
|
+
current.append(token)
|
|
665
|
+
grouped.append(current)
|
|
666
|
+
return grouped
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def command_word(statement):
|
|
670
|
+
"""The program a statement runs, and the tokens after it.
|
|
671
|
+
|
|
672
|
+
Steps over leading variable assignments and over wrappers that do not
|
|
673
|
+
change what runs. Reading a wrapper as the command is how a payload hides
|
|
674
|
+
from a guard that inspects only a statement's first word.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
statement: One statement's tokens.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
A triple (token, program, args); (None, None, []) when no program is
|
|
681
|
+
named. The raw token is retained because `./edit.sh` has no interpreter:
|
|
682
|
+
its command word is itself the executed script.
|
|
683
|
+
"""
|
|
684
|
+
index = 0
|
|
685
|
+
while index < len(statement):
|
|
686
|
+
token = statement[index]
|
|
687
|
+
if "=" in token and not token.startswith(("=", "-")):
|
|
688
|
+
index += 1
|
|
689
|
+
continue
|
|
690
|
+
program = token.rsplit("/", 1)[-1]
|
|
691
|
+
if program not in FOLLOW_WRAPPERS:
|
|
692
|
+
return (token, program, statement[index + 1 :])
|
|
693
|
+
separate, positional = FOLLOW_WRAPPERS[program]
|
|
694
|
+
index += 1
|
|
695
|
+
while index < len(statement) and statement[index].startswith("-"):
|
|
696
|
+
option = statement[index]
|
|
697
|
+
if option == "--":
|
|
698
|
+
index += 1
|
|
699
|
+
break
|
|
700
|
+
index += 2 if option in separate else 1
|
|
701
|
+
index += positional
|
|
702
|
+
return (None, None, [])
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def shell_script_operand(args):
|
|
706
|
+
"""The file a shell invocation runs, or None when it runs no script file.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
args: The tokens following the shell's own name.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
The operand token, or None for an interactive shell or a `-c` string.
|
|
713
|
+
"""
|
|
714
|
+
for token in args:
|
|
715
|
+
if token == "--":
|
|
716
|
+
continue
|
|
717
|
+
if token.startswith(("-", "+")):
|
|
718
|
+
# A single-dash cluster containing `c` carries a command string,
|
|
719
|
+
# not a script file.
|
|
720
|
+
if not token.startswith("--") and "c" in token[1:]:
|
|
721
|
+
return None
|
|
722
|
+
continue
|
|
723
|
+
return token
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def shell_command_string(statement):
|
|
728
|
+
"""The inline command a shell executes with `-c`, or None.
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
statement: One statement's tokens.
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
The command string operand, or None when this is not a shell `-c` call.
|
|
735
|
+
"""
|
|
736
|
+
_token, program, args = command_word(statement)
|
|
737
|
+
if program not in SHELL_PROGRAMS:
|
|
738
|
+
return None
|
|
739
|
+
for index, token in enumerate(args):
|
|
740
|
+
if token == "--":
|
|
741
|
+
return None
|
|
742
|
+
if token.startswith("-") and not token.startswith("--") and "c" in token[1:]:
|
|
743
|
+
command_index = index + 1
|
|
744
|
+
if command_index < len(args) and args[command_index] == "--":
|
|
745
|
+
command_index += 1
|
|
746
|
+
return args[command_index] if command_index < len(args) else None
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def write_targets(statement):
|
|
751
|
+
"""Paths this statement WRITES.
|
|
752
|
+
|
|
753
|
+
Reads never fire. A path has to be the target of an output redirection, or
|
|
754
|
+
an operand of `tee` or of an in-place `sed`. That is the whole distinction
|
|
755
|
+
this guard turns on: `grep -n x <managed>` names the same path and stays
|
|
756
|
+
allowed, and following a merely-named path is the known-wrong fix that
|
|
757
|
+
refuses ordinary reads elsewhere in this family.
|
|
758
|
+
|
|
759
|
+
Extraction is deliberately GENEROUS for `tee` and `sed`: every non-flag
|
|
760
|
+
operand is offered. It costs nothing, because the caller classifies each
|
|
761
|
+
candidate by resolving it against the installed package, and a `sed` script
|
|
762
|
+
expression such as `s/a/b/` resolves to no template. Being narrow here is
|
|
763
|
+
precisely what left the old `sed -i` arm dead in every spelling.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
statement: One statement's tokens.
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
Candidate paths this statement writes.
|
|
770
|
+
"""
|
|
771
|
+
found = []
|
|
772
|
+
_token, program, args = command_word(statement)
|
|
773
|
+
for index, token in enumerate(statement):
|
|
774
|
+
if token in REDIRECT_OUT and index + 1 < len(statement):
|
|
775
|
+
found.append(statement[index + 1])
|
|
776
|
+
if program == "tee":
|
|
777
|
+
found.extend(token for token in args if not token.startswith("-"))
|
|
778
|
+
elif program == "sed" and any(IN_PLACE.match(token) for token in args):
|
|
779
|
+
found.extend(token for token in args if not token.startswith("-"))
|
|
780
|
+
return found
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def executed_script(statement):
|
|
784
|
+
"""The path this statement EXECUTES, or None.
|
|
785
|
+
|
|
786
|
+
Only a COMMAND POSITION can execute something. A path anywhere else is an
|
|
787
|
+
argument, and an argument is data.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
statement: One statement's tokens.
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
The operand token naming a script that will run, or None.
|
|
794
|
+
"""
|
|
795
|
+
token, program, args = command_word(statement)
|
|
796
|
+
if program is None:
|
|
797
|
+
return None
|
|
798
|
+
if program in SHELL_PROGRAMS:
|
|
799
|
+
for index, token in enumerate(statement):
|
|
800
|
+
# `bash < script.sh` runs that file as surely as `bash script.sh`.
|
|
801
|
+
if token == REDIRECT_IN and index + 1 < len(statement):
|
|
802
|
+
return statement[index + 1]
|
|
803
|
+
return shell_script_operand(args)
|
|
804
|
+
if program in SOURCE_BUILTINS:
|
|
805
|
+
return args[0] if args else None
|
|
806
|
+
# A script in command position executes through its shebang. This also
|
|
807
|
+
# covers a direct path after wrappers such as `sudo ./edit.sh`; `command_word`
|
|
808
|
+
# deliberately keeps the raw token while using its basename for wrapper and
|
|
809
|
+
# shell classification.
|
|
810
|
+
if resolve(token) is not None:
|
|
811
|
+
return token
|
|
812
|
+
return None
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def resolve(token):
|
|
816
|
+
"""An existing readable script a token names, or None.
|
|
817
|
+
|
|
818
|
+
A computed target (`bash "$SCRIPT"`) is NOT followed and NOT refused. This
|
|
819
|
+
guard's harm is a template that silently forks — recoverable, and reported
|
|
820
|
+
by `lisa doctor` on every run — so failing closed on every `bash "$SCRIPT"`
|
|
821
|
+
in every host project would cost far more than it buys. Its sibling
|
|
822
|
+
`block-no-verify` DOES fail closed on the identical shape, because an
|
|
823
|
+
unverified commit is not recoverable. The divergence is deliberate, and it
|
|
824
|
+
is recorded here so the next reader does not read it as an oversight.
|
|
825
|
+
|
|
826
|
+
Args:
|
|
827
|
+
token: The operand naming a script.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
A path, or None.
|
|
831
|
+
"""
|
|
832
|
+
if COMPUTED_VALUE.search(token):
|
|
833
|
+
return None
|
|
834
|
+
text = token.strip().strip("'\"")
|
|
835
|
+
if not text or text == "-":
|
|
836
|
+
return None
|
|
837
|
+
if os.path.isabs(text):
|
|
838
|
+
candidates = [text]
|
|
839
|
+
else:
|
|
840
|
+
candidates = [os.path.join(project, text), os.path.join(os.getcwd(), text)]
|
|
841
|
+
for candidate in candidates:
|
|
842
|
+
try:
|
|
843
|
+
if not os.path.isfile(candidate):
|
|
844
|
+
continue
|
|
845
|
+
if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
|
|
846
|
+
continue
|
|
847
|
+
except OSError:
|
|
848
|
+
continue
|
|
849
|
+
return candidate
|
|
850
|
+
return None
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def collect(text, depth, seen, out):
|
|
854
|
+
"""Accumulate every write target in this text and in the scripts it runs.
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
text: A command line, or a followed script's contents.
|
|
858
|
+
depth: Number of scripts already followed.
|
|
859
|
+
seen: Real paths already inspected, so a cycle terminates.
|
|
860
|
+
out: Accumulator for candidate paths.
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
Nothing; `out` is extended in place.
|
|
864
|
+
"""
|
|
865
|
+
tokens = tokenize(text)
|
|
866
|
+
if tokens is None:
|
|
867
|
+
return
|
|
868
|
+
for statement in statements(tokens):
|
|
869
|
+
if not statement:
|
|
870
|
+
continue
|
|
871
|
+
out.extend(write_targets(statement))
|
|
872
|
+
nested = shell_command_string(statement)
|
|
873
|
+
if nested is not None and depth < FOLLOW_MAX_DEPTH:
|
|
874
|
+
collect(nested, depth + 1, seen, out)
|
|
875
|
+
operand = executed_script(statement)
|
|
876
|
+
if operand is None:
|
|
877
|
+
continue
|
|
878
|
+
if depth >= FOLLOW_MAX_DEPTH or len(seen) >= FOLLOW_MAX_FILES:
|
|
879
|
+
continue
|
|
880
|
+
path = resolve(operand)
|
|
881
|
+
if path is None:
|
|
882
|
+
continue
|
|
883
|
+
try:
|
|
884
|
+
key = os.path.realpath(path)
|
|
885
|
+
except OSError:
|
|
886
|
+
key = path
|
|
887
|
+
if key in seen:
|
|
888
|
+
continue
|
|
889
|
+
seen.add(key)
|
|
890
|
+
try:
|
|
891
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
892
|
+
body = handle.read(FOLLOW_MAX_BYTES)
|
|
893
|
+
except OSError:
|
|
894
|
+
continue
|
|
895
|
+
collect(strip_comments(body), depth + 1, seen, out)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
targets = []
|
|
899
|
+
collect(command, 0, set(), targets)
|
|
900
|
+
for target in targets:
|
|
901
|
+
cleaned = target.strip().strip("'\"")
|
|
902
|
+
if cleaned:
|
|
903
|
+
print(cleaned)
|
|
904
|
+
PY
|
|
905
|
+
analyzer_stderr="$(mktemp "${TMPDIR:-/tmp}/lisa-managed-edit-analyzer.XXXXXX")" || {
|
|
906
|
+
printf 'block-managed-file-edits: could not allocate analyzer stderr capture; Bash write protection is NOT active\n' >&2
|
|
907
|
+
exit 0
|
|
908
|
+
}
|
|
909
|
+
if write_targets="$(printf '%s' "$analyzer" |
|
|
910
|
+
MANAGED_EDIT_COMMAND="$command_str" \
|
|
911
|
+
MANAGED_EDIT_PROJECT="$project_root" \
|
|
912
|
+
python3 - 2>"$analyzer_stderr")"; then
|
|
913
|
+
analyzer_status=0
|
|
914
|
+
else
|
|
915
|
+
analyzer_status=$?
|
|
916
|
+
fi
|
|
917
|
+
if [ "$analyzer_status" -ne 0 ]; then
|
|
918
|
+
printf 'block-managed-file-edits: Bash analyzer failed (exit %s); Bash write protection is NOT active\n' "$analyzer_status" >&2
|
|
919
|
+
while IFS= read -r analyzer_line; do
|
|
920
|
+
[ -n "$analyzer_line" ] && printf ' python3: %s\n' "$analyzer_line" >&2
|
|
921
|
+
done <"$analyzer_stderr"
|
|
922
|
+
rm -f "$analyzer_stderr"
|
|
923
|
+
exit 0
|
|
924
|
+
fi
|
|
925
|
+
rm -f "$analyzer_stderr"
|
|
926
|
+
while IFS= read -r token; do
|
|
927
|
+
[ -n "$token" ] || continue
|
|
928
|
+
if source_path="$(managed_source "$token")"; then
|
|
929
|
+
refuse "$token" "$source_path" "$(relative_path "$token")"
|
|
930
|
+
fi
|
|
931
|
+
done <<EOF
|
|
932
|
+
$write_targets
|
|
933
|
+
EOF
|
|
934
|
+
;;
|
|
935
|
+
esac
|
|
936
|
+
|
|
937
|
+
exit 0
|