@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.
Files changed (83) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +17 -5
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/dist/opencode/hooks-installer.d.ts.map +1 -1
  9. package/dist/opencode/hooks-installer.js +1 -0
  10. package/dist/opencode/hooks-installer.js.map +1 -1
  11. package/dist/opencode/plugin-catalog.d.ts.map +1 -1
  12. package/dist/opencode/plugin-catalog.js +5 -0
  13. package/dist/opencode/plugin-catalog.js.map +1 -1
  14. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +937 -0
  15. package/dist/opencode/plugin-templates/lisa-block-managed-file-edits.ts +83 -0
  16. package/package.json +6 -5
  17. package/plugins/lisa/.claude-plugin/plugin.json +9 -1
  18. package/plugins/lisa/.codex-plugin/hooks.json +8 -0
  19. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa/hooks/block-managed-file-edits.agy.sh +63 -0
  21. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +8 -0
  22. package/plugins/lisa-agy/hooks/block-managed-file-edits.agy.sh +63 -0
  23. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +937 -0
  24. package/plugins/lisa-agy/hooks.json +13 -0
  25. package/plugins/lisa-agy/plugin.json +1 -1
  26. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  29. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-copilot/.claude-plugin/plugin.json +9 -1
  32. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +8 -0
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/hooks/hooks.json +8 -0
  35. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +8 -0
  36. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-agy/plugin.json +1 -1
  39. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  59. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-agy/plugin.json +1 -1
  64. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  69. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  74. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/src/base/.claude-plugin/plugin.json +9 -1
  77. package/plugins/src/base/hooks/block-managed-file-edits.agy.sh +63 -0
  78. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +8 -0
  79. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +8 -0
  80. package/scripts/check-typecheck-tests.mjs +147 -0
  81. package/scripts/copy-opencode-plugin-templates.mjs +1 -0
  82. package/scripts/generate-agy-plugin-artifacts.mjs +12 -0
  83. 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