@codyswann/lisa 4.34.4 → 4.34.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +40 -1
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +436 -9
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +636 -101
  4. package/all/copy-overwrite/scripts/lisa-test-node.mjs +44 -1
  5. package/dist/codex/scripts/block-no-verify.sh +608 -99
  6. package/dist/configs/vitest/scratch-global-setup.d.ts +29 -0
  7. package/dist/configs/vitest/scratch-global-setup.d.ts.map +1 -1
  8. package/dist/configs/vitest/scratch-global-setup.js +77 -12
  9. package/dist/configs/vitest/scratch-global-setup.js.map +1 -1
  10. package/dist/core/config-shadowing.d.ts +72 -0
  11. package/dist/core/config-shadowing.d.ts.map +1 -0
  12. package/dist/core/config-shadowing.js +152 -0
  13. package/dist/core/config-shadowing.js.map +1 -0
  14. package/dist/core/config.d.ts +16 -1
  15. package/dist/core/config.d.ts.map +1 -1
  16. package/dist/core/config.js +1 -0
  17. package/dist/core/config.js.map +1 -1
  18. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  19. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  20. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  21. package/dist/core/lisa.d.ts +21 -0
  22. package/dist/core/lisa.d.ts.map +1 -1
  23. package/dist/core/lisa.js +46 -0
  24. package/dist/core/lisa.js.map +1 -1
  25. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  26. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  27. package/dist/core/upstream-evidence-manifest.js +19 -11
  28. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  29. package/dist/opencode/plugin-templates/block-no-verify.sh +636 -101
  30. package/dist/strategies/copy-overwrite.d.ts +42 -0
  31. package/dist/strategies/copy-overwrite.d.ts.map +1 -1
  32. package/dist/strategies/copy-overwrite.js +69 -5
  33. package/dist/strategies/copy-overwrite.js.map +1 -1
  34. package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +138 -11
  35. package/expo/create-only/.github/workflows/deploy.yml +51 -4
  36. package/package.json +4 -4
  37. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa/hooks/block-managed-file-edits.sh +436 -9
  40. package/plugins/lisa/hooks/block-no-verify.agy.sh +608 -99
  41. package/plugins/lisa/hooks/block-no-verify.sh +636 -101
  42. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +608 -99
  43. package/plugins/lisa-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  47. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +436 -9
  51. package/plugins/lisa-copilot/hooks/block-no-verify.sh +636 -101
  52. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +436 -9
  54. package/plugins/lisa-cursor/hooks/block-no-verify.sh +636 -101
  55. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-expo-agy/plugin.json +1 -1
  58. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  68. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  73. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  78. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails-agy/plugin.json +1 -1
  83. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  88. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  93. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/src/base/hooks/block-managed-file-edits.sh +436 -9
  96. package/plugins/src/base/hooks/block-no-verify.agy.sh +608 -99
  97. package/plugins/src/base/hooks/block-no-verify.sh +636 -101
  98. package/rails/create-only/.github/workflows/deploy.yml +45 -1
  99. package/scripts/two-channel-couplings.json +1 -1
@@ -77,7 +77,7 @@ import {
77
77
  statSync,
78
78
  } from "node:fs";
79
79
  import { createRequire } from "node:module";
80
- import { dirname, join, parse } from "node:path";
80
+ import { dirname, isAbsolute, join, parse, relative } from "node:path";
81
81
  import { fileURLToPath } from "node:url";
82
82
 
83
83
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
@@ -6109,6 +6109,34 @@ export function artifactDigest(file) {
6109
6109
  }
6110
6110
  }
6111
6111
 
6112
+ /**
6113
+ * WHICH COPY the digests above were taken from, rendered for a reader.
6114
+ *
6115
+ * A digest alone cannot be checked. Several copies of this registry can be on
6116
+ * one runner at once — the installed package under `node_modules`, the copy
6117
+ * `lisa apply` writes into `scripts/`, and this repository's own source tree —
6118
+ * and a stamp naming none of them is a hash a reader has no way to attribute.
6119
+ * The failure that made this load-bearing was a stamp resolved on a filesystem
6120
+ * the gates had not yet been installed onto: it named bytes that decided
6121
+ * nothing, on the run whose verdicts a reader was trying to date, and read as
6122
+ * the one fact that could not be mistaken.
6123
+ *
6124
+ * Relative to the working directory when it is inside it, because that is the
6125
+ * form the resolving shell prints and the form two reports can be compared in;
6126
+ * absolute otherwise, rather than a `../..` chain that names a location only
6127
+ * this process can resolve.
6128
+ * @param {string} directory The directory whose artifacts were digested.
6129
+ * @param {string} [cwd] The directory paths are rendered relative to.
6130
+ * @returns {string} The path, relative where that is meaningful.
6131
+ */
6132
+ export function identityRegistryPath(directory, cwd = process.cwd()) {
6133
+ const rendered = relative(cwd, directory);
6134
+ if (rendered === "") return ".";
6135
+ if (isAbsolute(rendered) || rendered.split(/[\\/]/).includes(".."))
6136
+ return directory;
6137
+ return rendered;
6138
+ }
6139
+
6112
6140
  /**
6113
6141
  * WHICH Lisa is running: the surface, the version, the ref, and the bytes.
6114
6142
  *
@@ -6125,15 +6153,18 @@ export function artifactDigest(file) {
6125
6153
  * @param {object} [options] Inputs.
6126
6154
  * @param {Record<string,string|undefined>} [options.env] The environment.
6127
6155
  * @param {string} [options.directory] Where the enforcement scripts live.
6156
+ * @param {string} [options.cwd] What `registry_path` is rendered relative to.
6128
6157
  * @returns {object} The identity record.
6129
6158
  */
6130
6159
  export function lisaIdentity({
6131
6160
  env = process.env,
6132
6161
  directory = SCRIPTS_DIRECTORY,
6162
+ cwd = process.cwd(),
6133
6163
  } = {}) {
6134
6164
  return {
6135
6165
  surface: env.GITHUB_ACTIONS === "true" ? "ci" : "local",
6136
6166
  registry_version: registryVersion(),
6167
+ registry_path: identityRegistryPath(directory, cwd),
6137
6168
  workflow_ref: env.GITHUB_WORKFLOW_REF ?? null,
6138
6169
  workflow_sha: env.GITHUB_WORKFLOW_SHA ?? null,
6139
6170
  artifacts: Object.fromEntries(
@@ -6169,6 +6200,7 @@ export function formatIdentityLine(identity) {
6169
6200
  return [
6170
6201
  `🔖 Lisa identity — surface=${identity.surface}`,
6171
6202
  `package=${LISA_PACKAGE}@${identity.registry_version ?? IDENTITY_UNKNOWN}`,
6203
+ `registry=${identity.registry_path ?? IDENTITY_UNKNOWN}`,
6172
6204
  `workflow=${identityWorkflow(identity)}`,
6173
6205
  ...Object.entries(identity.artifacts ?? {}).map(
6174
6206
  ([name, sha]) => `${name}=${sha ?? IDENTITY_UNKNOWN}`
@@ -6186,6 +6218,7 @@ export function formatIdentityMarkdown(identity) {
6186
6218
  const rows = [
6187
6219
  ["surface", identity.surface],
6188
6220
  ["package", `${LISA_PACKAGE}@${version}`],
6221
+ ["registry path", identity.registry_path ?? IDENTITY_UNKNOWN],
6189
6222
  ["workflow ref", identity.workflow_ref ?? IDENTITY_UNKNOWN],
6190
6223
  ["workflow sha", identity.workflow_sha ?? IDENTITY_UNKNOWN],
6191
6224
  ...Object.entries(identity.artifacts ?? {}).map(([name, sha]) => [
@@ -6205,6 +6238,12 @@ export function formatIdentityMarkdown(identity) {
6205
6238
  "fields, so the two can be compared without either repository: equal " +
6206
6239
  "digests mean the same enforcement bytes ran, and a differing " +
6207
6240
  "`workflow sha` dates any claim made from this run.",
6241
+ "",
6242
+ "`registry path` names WHICH copy those digests were taken from. Several " +
6243
+ "live at once on one runner — the installed package, the copy `lisa " +
6244
+ "apply` writes into `scripts/`, and a repository's own source tree — so " +
6245
+ "a digest attributed to none of them cannot be checked against the copy " +
6246
+ "a gate job executed.",
6208
6247
  ].join("\n");
6209
6248
  }
6210
6249
 
@@ -56,8 +56,30 @@
56
56
  # Blocked signatures:
57
57
  # 1. Write / Edit / MultiEdit / NotebookEdit whose target resolves to a
58
58
  # copy-overwrite template in the installed Lisa package;
59
- # 2. Bash output redirection (`>`, `>>`, `>|`), `tee`, or `sed -i` aimed at
60
- # one. Reads never fire.
59
+ # 2. Bash output redirection (`>`, `>>`, `>|`), `tee`, or an in-place `sed`
60
+ # aimed at one — `-i`, `-i ''`, `-i.bak`, a cluster such as `-ni`, and the
61
+ # long `--in-place[=SUFFIX]`. Reads never fire;
62
+ # 3. any of those inside a script the command EXECUTES — `bash edit.sh`,
63
+ # `sh`, `source`, `.`, `bash < edit.sh`, and behind wrappers that carry an
64
+ # operand of their own (`nice -n 5`, `timeout 5`, `sudo`).
65
+ #
66
+ # ## What signature 3 deliberately does NOT do
67
+ #
68
+ # A path is followed ONLY when the command EXECUTES it. `grep -n x edit.sh`,
69
+ # `wc -l edit.sh`, `git diff edit.sh` and a test run over it name a file as
70
+ # DATA, and following those is the known-wrong fix this guard family has already
71
+ # made once: it refuses ordinary reads and attributes a file's capability to a
72
+ # command that merely mentions it. Command POSITION decides. Taint does not
73
+ # propagate any further either — a path a followed script merely NAMES is data
74
+ # one file further out, not a third hop.
75
+ #
76
+ # ## Parity gap, recorded rather than silently dropped
77
+ #
78
+ # This guard has no Antigravity, Codex or OpenCode port — only the Claude
79
+ # reference and the copies generated from it. Its siblings do. That predates
80
+ # this change and is not closed by it: opening three ports is its own work, and
81
+ # doing it here would triple the surface under review. Recorded so it reads as a
82
+ # known gap rather than an oversight.
61
83
  #
62
84
  # Exemptions (allowed):
63
85
  # - `LISA_ALLOW_MANAGED_FILE_WRITE` set — the operator's explicit override,
@@ -65,6 +87,13 @@
65
87
  # - paths under `node_modules/` or `dist/` — vendored copies, not the host's;
66
88
  # - the Lisa source repository itself, where these files are the originals and
67
89
  # editing them is the entire point.
90
+ #
91
+ # The line below lets `lisa apply` tell a downstream copy of this guard that is
92
+ # BEHIND from one that is AHEAD. Byte comparison cannot: both look like "differs
93
+ # from mine", and guessing "behind" is how a fork's stronger guard gets silently
94
+ # replaced by a weaker upstream one. Add a name here in the same commit that
95
+ # closes a vector.
96
+ # 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
68
97
  set -euo pipefail
69
98
 
70
99
  input="$(cat)"
@@ -366,19 +395,417 @@ EOF
366
395
  Bash)
367
396
  command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
368
397
  [ -n "$command_str" ] || exit 0
369
- # Write signatures only. A bare mention is a read and stays allowed — the
370
- # path has to be the target of a redirection, a tee, or an in-place sed.
371
- # `>>?\|?` covers `>`, `>>`, and the noclobber override `>|`.
398
+ # A missing interpreter must be announced rather than swallowed. This guard
399
+ # degrades to "allow" — a hook that cannot parse its input cannot tell a
400
+ # write from a read — but a guard that is silently absent reads exactly like
401
+ # a guard that is passing.
402
+ if ! command -v python3 >/dev/null 2>&1; then
403
+ printf 'block-managed-file-edits: python3 not found; Bash write protection is NOT active\n' >&2
404
+ exit 0
405
+ fi
406
+ # Why a tokenizer replaced the two-stage regex.
407
+ #
408
+ # The old extraction was `grep -Eo` for a redirect / tee / in-place sed,
409
+ # followed by a `sed -E` strip of the matched prefix. MEASURED against it:
410
+ # `tee <path>` and `tee -a <path>` extracted correctly, and EVERY `sed -i`
411
+ # spelling extracted NOTHING — `-i`, `-i ''` and `-i.bak` alike. The
412
+ # `grep` matched; the strip pattern `sed[[:space:]]+[^|;&]*-i[^|;&]*` is
413
+ # greedy, so it consumed the path along with the flags and left an empty
414
+ # token. The arm was DEAD, not merely missing macOS's two-token spelling.
415
+ #
416
+ # A regex cannot answer the question this guard actually asks — is this path
417
+ # WRITTEN or merely NAMED — because that depends on token position, which is
418
+ # what a tokenizer is for.
419
+ #
420
+ # The analyzer is read into a variable by `read -d ''` rather than run
421
+ # inside a `$(python3 - <<'PY' … PY)`. Bash has to find the closing paren of
422
+ # a command substitution by re-scanning its text, and a heredoc body nested
423
+ # there is scanned too: the lone backtick in `re.compile(r"[$`]")` below
424
+ # reads as an unterminated backtick substitution and the whole script fails
425
+ # to parse. Keeping the heredoc out of the substitution is the fix.
426
+ analyzer=''
427
+ IFS= read -r -d '' analyzer <<'PY' || true
428
+ import os
429
+ import re
430
+ import shlex
431
+
432
+ command = os.environ.get("MANAGED_EDIT_COMMAND", "")
433
+ project = os.environ.get("MANAGED_EDIT_PROJECT", "") or os.getcwd()
434
+
435
+ # Following a script costs a read on every intercepted command, so the walk is
436
+ # bounded three ways, and a file past the cap is skipped rather than half-read:
437
+ # a truncated scan reports a confident ALLOW about text it never saw.
438
+ FOLLOW_MAX_BYTES = 262144
439
+ FOLLOW_MAX_FILES = 8
440
+ FOLLOW_MAX_DEPTH = 3
441
+
442
+ # Spelled with chr() rather than backslash escapes on purpose. This source is
443
+ # embedded in a shell heredoc and copied verbatim through several generators
444
+ # into every shipped plugin; a backslash literal is the one thing in it most
445
+ # likely to be mangled in transit, and the mangling would be silent.
446
+ BACKSLASH = chr(92)
447
+ NEWLINE = chr(10)
448
+
449
+ STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
450
+ # `>`, `>>`, and the noclobber override `>|`.
451
+ REDIRECT_OUT = {">", ">>", ">|"}
452
+ REDIRECT_IN = "<"
453
+
454
+ SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
455
+ SOURCE_BUILTINS = {"source", "."}
456
+
457
+ # Wrappers that run what FOLLOWS them without changing what it is, mapped to
458
+ # (options whose value is a SEPARATE token, positional operands consumed).
459
+ # The positional count is why this is a table rather than a set: `timeout 5
460
+ # bash x` puts an operand between the wrapper and the interpreter, so a walk
461
+ # that steps over `-flags` only stops at `5` and never reaches `bash`.
462
+ FOLLOW_WRAPPERS = {
463
+ "builtin": (frozenset(), 0),
464
+ "command": (frozenset(), 0),
465
+ "exec": (frozenset({"-a"}), 0),
466
+ "nice": (frozenset({"-n", "--adjustment"}), 0),
467
+ "nohup": (frozenset(), 0),
468
+ "setsid": (frozenset(), 0),
469
+ "stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
470
+ "sudo": (
471
+ frozenset(
472
+ {
473
+ "-C", "--close-from", "-g", "--group", "-h", "--host",
474
+ "-p", "--prompt", "-r", "--role", "-t", "--type",
475
+ "-U", "--other-user", "-u", "--user",
476
+ }
477
+ ),
478
+ 0,
479
+ ),
480
+ "time": (frozenset(), 0),
481
+ "timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
482
+ }
483
+
484
+ # `-i`, `-i.bak`, a cluster such as `-ni`, and the long `--in-place[=SUFFIX]`.
485
+ IN_PLACE = re.compile(r"^(--in-place(=.*)?|-[a-zA-Z]*i.*)$")
486
+ # A value the SHELL computes; this hook runs before expansion and cannot know
487
+ # which file it will name.
488
+ COMPUTED_VALUE = re.compile(r"[$`]")
489
+
490
+
491
+ def normalise_lines(text):
492
+ """Spell line breaks as statement separators.
493
+
494
+ A newline ends a command exactly as `;` does, but shlex treats it as plain
495
+ whitespace — so two consecutive lines of a followed script would read as
496
+ one invocation and only the first program would be classified. A
497
+ backslash-newline is joined first, because there the newline is NOT a
498
+ boundary.
499
+
500
+ Args:
501
+ text: A command line or a script's contents.
502
+
503
+ Returns:
504
+ The same text with line breaks spelled as separators.
505
+ """
506
+ joined = text.replace(BACKSLASH + NEWLINE, " ")
507
+ return joined.replace(NEWLINE, " ; ")
508
+
509
+
510
+ def tokenize(text):
511
+ """Tokenize with shell operators kept as their own tokens.
512
+
513
+ Args:
514
+ text: A command line or a script's contents.
515
+
516
+ Returns:
517
+ The token list, or None when the text does not lex.
518
+ """
519
+ lexer = shlex.shlex(normalise_lines(text), posix=True, punctuation_chars=True)
520
+ lexer.whitespace_split = True
521
+ # `#` is not a comment introducer on a typed command line, and letting shlex
522
+ # treat it as one silently truncates the rest of the line.
523
+ lexer.commenters = ""
524
+ try:
525
+ return list(lexer)
526
+ except ValueError:
527
+ return None
528
+
529
+
530
+ def strip_comments(text):
531
+ """Drop `#` comments from a FOLLOWED SCRIPT, quote-aware.
532
+
533
+ Applied to file contents and never to a typed command. A comment cannot
534
+ execute, and Lisa's own managed templates discuss redirection and `sed -i`
535
+ against managed paths in their headers — classifying a followed file
536
+ without dropping its comments reads documentation as an edit.
537
+
538
+ Args:
539
+ text: A script's contents.
540
+
541
+ Returns:
542
+ The same text with unquoted comments removed.
543
+ """
544
+ output = []
545
+ for line in text.split(NEWLINE):
546
+ quote = ""
547
+ escaped = False
548
+ cut = None
549
+ for position, character in enumerate(line):
550
+ if escaped:
551
+ escaped = False
552
+ continue
553
+ if character == BACKSLASH and quote != "'":
554
+ escaped = True
555
+ continue
556
+ if quote:
557
+ if character == quote:
558
+ quote = ""
559
+ continue
560
+ if character in "'\"":
561
+ quote = character
562
+ continue
563
+ # A `#` opens a comment only at the start of a word, so `x#y` and
564
+ # `${x#y}` keep their hashes.
565
+ if character == "#" and (position == 0 or line[position - 1].isspace()):
566
+ cut = position
567
+ break
568
+ output.append(line if cut is None else line[:cut])
569
+ return NEWLINE.join(output)
570
+
571
+
572
+ def statements(tokens):
573
+ """Split a token list at statement separators, keeping redirects inline.
574
+
575
+ Redirect operators are deliberately NOT separators here: `>` and its target
576
+ have to stay in the same group, because the pair is the write signature.
577
+
578
+ Args:
579
+ tokens: The operator-aware token list.
580
+
581
+ Returns:
582
+ A list of statements.
583
+ """
584
+ grouped = []
585
+ current = []
586
+ for token in tokens:
587
+ if token in STATEMENT_SEPARATORS:
588
+ grouped.append(current)
589
+ current = []
590
+ continue
591
+ current.append(token)
592
+ grouped.append(current)
593
+ return grouped
594
+
595
+
596
+ def command_word(statement):
597
+ """The program a statement runs, and the tokens after it.
598
+
599
+ Steps over leading variable assignments and over wrappers that do not
600
+ change what runs. Reading a wrapper as the command is how a payload hides
601
+ from a guard that inspects only a statement's first word.
602
+
603
+ Args:
604
+ statement: One statement's tokens.
605
+
606
+ Returns:
607
+ A pair (program, args); (None, []) when no program is named.
608
+ """
609
+ index = 0
610
+ while index < len(statement):
611
+ token = statement[index]
612
+ if "=" in token and not token.startswith(("=", "-")):
613
+ index += 1
614
+ continue
615
+ program = token.rsplit("/", 1)[-1]
616
+ if program not in FOLLOW_WRAPPERS:
617
+ return (program, statement[index + 1 :])
618
+ separate, positional = FOLLOW_WRAPPERS[program]
619
+ index += 1
620
+ while index < len(statement) and statement[index].startswith("-"):
621
+ option = statement[index]
622
+ if option == "--":
623
+ index += 1
624
+ break
625
+ index += 2 if option in separate else 1
626
+ index += positional
627
+ return (None, [])
628
+
629
+
630
+ def shell_script_operand(args):
631
+ """The file a shell invocation runs, or None when it runs no script file.
632
+
633
+ Args:
634
+ args: The tokens following the shell's own name.
635
+
636
+ Returns:
637
+ The operand token, or None for an interactive shell or a `-c` string.
638
+ """
639
+ for token in args:
640
+ if token == "--":
641
+ continue
642
+ if token.startswith(("-", "+")):
643
+ # A single-dash cluster containing `c` carries a command string,
644
+ # not a script file.
645
+ if not token.startswith("--") and "c" in token[1:]:
646
+ return None
647
+ continue
648
+ return token
649
+ return None
650
+
651
+
652
+ def write_targets(statement):
653
+ """Paths this statement WRITES.
654
+
655
+ Reads never fire. A path has to be the target of an output redirection, or
656
+ an operand of `tee` or of an in-place `sed`. That is the whole distinction
657
+ this guard turns on: `grep -n x <managed>` names the same path and stays
658
+ allowed, and following a merely-named path is the known-wrong fix that
659
+ refuses ordinary reads elsewhere in this family.
660
+
661
+ Extraction is deliberately GENEROUS for `tee` and `sed`: every non-flag
662
+ operand is offered. It costs nothing, because the caller classifies each
663
+ candidate by resolving it against the installed package, and a `sed` script
664
+ expression such as `s/a/b/` resolves to no template. Being narrow here is
665
+ precisely what left the old `sed -i` arm dead in every spelling.
666
+
667
+ Args:
668
+ statement: One statement's tokens.
669
+
670
+ Returns:
671
+ Candidate paths this statement writes.
672
+ """
673
+ found = []
674
+ program, args = command_word(statement)
675
+ for index, token in enumerate(statement):
676
+ if token in REDIRECT_OUT and index + 1 < len(statement):
677
+ found.append(statement[index + 1])
678
+ if program == "tee":
679
+ found.extend(token for token in args if not token.startswith("-"))
680
+ elif program == "sed" and any(IN_PLACE.match(token) for token in args):
681
+ found.extend(token for token in args if not token.startswith("-"))
682
+ return found
683
+
684
+
685
+ def executed_script(statement):
686
+ """The path this statement EXECUTES, or None.
687
+
688
+ Only a COMMAND POSITION can execute something. A path anywhere else is an
689
+ argument, and an argument is data.
690
+
691
+ Args:
692
+ statement: One statement's tokens.
693
+
694
+ Returns:
695
+ The operand token naming a script that will run, or None.
696
+ """
697
+ program, args = command_word(statement)
698
+ if program is None:
699
+ return None
700
+ if program in SHELL_PROGRAMS:
701
+ for index, token in enumerate(statement):
702
+ # `bash < script.sh` runs that file as surely as `bash script.sh`.
703
+ if token == REDIRECT_IN and index + 1 < len(statement):
704
+ return statement[index + 1]
705
+ return shell_script_operand(args)
706
+ if program in SOURCE_BUILTINS:
707
+ return args[0] if args else None
708
+ return None
709
+
710
+
711
+ def resolve(token):
712
+ """An existing readable script a token names, or None.
713
+
714
+ A computed target (`bash "$SCRIPT"`) is NOT followed and NOT refused. This
715
+ guard's harm is a template that silently forks — recoverable, and reported
716
+ by `lisa doctor` on every run — so failing closed on every `bash "$SCRIPT"`
717
+ in every host project would cost far more than it buys. Its sibling
718
+ `block-no-verify` DOES fail closed on the identical shape, because an
719
+ unverified commit is not recoverable. The divergence is deliberate, and it
720
+ is recorded here so the next reader does not read it as an oversight.
721
+
722
+ Args:
723
+ token: The operand naming a script.
724
+
725
+ Returns:
726
+ A path, or None.
727
+ """
728
+ if COMPUTED_VALUE.search(token):
729
+ return None
730
+ text = token.strip().strip("'\"")
731
+ if not text or text == "-":
732
+ return None
733
+ if os.path.isabs(text):
734
+ candidates = [text]
735
+ else:
736
+ candidates = [os.path.join(project, text), os.path.join(os.getcwd(), text)]
737
+ for candidate in candidates:
738
+ try:
739
+ if not os.path.isfile(candidate):
740
+ continue
741
+ if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
742
+ continue
743
+ except OSError:
744
+ continue
745
+ return candidate
746
+ return None
747
+
748
+
749
+ def collect(text, depth, seen, out):
750
+ """Accumulate every write target in this text and in the scripts it runs.
751
+
752
+ Args:
753
+ text: A command line, or a followed script's contents.
754
+ depth: Number of scripts already followed.
755
+ seen: Real paths already inspected, so a cycle terminates.
756
+ out: Accumulator for candidate paths.
757
+
758
+ Returns:
759
+ Nothing; `out` is extended in place.
760
+ """
761
+ tokens = tokenize(text)
762
+ if tokens is None:
763
+ return
764
+ for statement in statements(tokens):
765
+ if not statement:
766
+ continue
767
+ out.extend(write_targets(statement))
768
+ operand = executed_script(statement)
769
+ if operand is None:
770
+ continue
771
+ if depth >= FOLLOW_MAX_DEPTH or len(seen) >= FOLLOW_MAX_FILES:
772
+ continue
773
+ path = resolve(operand)
774
+ if path is None:
775
+ continue
776
+ try:
777
+ key = os.path.realpath(path)
778
+ except OSError:
779
+ key = path
780
+ if key in seen:
781
+ continue
782
+ seen.add(key)
783
+ try:
784
+ with open(path, encoding="utf-8", errors="replace") as handle:
785
+ body = handle.read(FOLLOW_MAX_BYTES)
786
+ except OSError:
787
+ continue
788
+ collect(strip_comments(body), depth + 1, seen, out)
789
+
790
+
791
+ targets = []
792
+ collect(command, 0, set(), targets)
793
+ for target in targets:
794
+ cleaned = target.strip().strip("'\"")
795
+ if cleaned:
796
+ print(cleaned)
797
+ PY
798
+ write_targets="$(printf '%s' "$analyzer" |
799
+ MANAGED_EDIT_COMMAND="$command_str" \
800
+ MANAGED_EDIT_PROJECT="$project_root" \
801
+ python3 - 2>/dev/null || true)"
372
802
  while IFS= read -r token; do
373
803
  [ -n "$token" ] || continue
374
804
  if source_path="$(managed_source "$token")"; then
375
805
  refuse "$token" "$source_path" "$(relative_path "$token")"
376
806
  fi
377
807
  done <<EOF
378
- $(printf '%s' "$command_str" |
379
- grep -Eo ">>?\|?[[:space:]]*['\"]?[^ |;&'\"]+|tee([[:space:]]+-[a-zA-Z]+)*[[:space:]]+['\"]?[^ |;&'\"]+|sed[[:space:]]+[^|;&]*-i[^|;&]*[[:space:]]['\"]?[^ |;&'\"]+" 2>/dev/null |
380
- sed -E "s/^(>>?\|?|tee([[:space:]]+-[a-zA-Z]+)*|sed[[:space:]]+[^|;&]*-i[^|;&]*)[[:space:]]*//" |
381
- tr -d "\"'" || true)
808
+ $write_targets
382
809
  EOF
383
810
  ;;
384
811
  esac