@codyswann/lisa 4.34.14 → 4.34.16

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 (94) hide show
  1. package/all/copy-overwrite/scripts/check-npm-publish-landed.mjs +250 -0
  2. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +3 -5
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +20 -3
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +33 -8
  5. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +80 -5
  6. package/all/copy-overwrite/scripts/lisa-work-item.mjs +1 -1
  7. package/dist/codex/scripts/block-no-verify.sh +80 -5
  8. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.js +28 -0
  10. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  11. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  12. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.js +24 -15
  14. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  15. package/dist/opencode/plugin-templates/block-no-verify.sh +80 -5
  16. package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +13 -7
  17. package/package.json +5 -5
  18. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  21. package/plugins/lisa/hooks/block-direct-issue-create.sh +20 -3
  22. package/plugins/lisa/hooks/block-managed-file-edits.sh +33 -8
  23. package/plugins/lisa/hooks/block-no-verify.agy.sh +80 -5
  24. package/plugins/lisa/hooks/block-no-verify.sh +80 -5
  25. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  26. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +20 -3
  27. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +80 -5
  28. package/plugins/lisa-agy/plugin.json +1 -1
  29. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  30. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  33. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +20 -3
  37. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +33 -8
  38. package/plugins/lisa-copilot/hooks/block-no-verify.sh +80 -5
  39. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  40. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +20 -3
  42. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +33 -8
  43. package/plugins/lisa-cursor/hooks/block-no-verify.sh +80 -5
  44. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  45. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-expo-agy/plugin.json +1 -1
  48. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  68. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-agy/plugin.json +1 -1
  73. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  78. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  83. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/src/base/hooks/block-direct-issue-create.sh +20 -3
  86. package/plugins/src/base/hooks/block-managed-file-edits.sh +33 -8
  87. package/plugins/src/base/hooks/block-no-verify.agy.sh +80 -5
  88. package/plugins/src/base/hooks/block-no-verify.sh +80 -5
  89. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  90. package/scripts/check-npm-publish-landed.mjs +30 -0
  91. package/scripts/two-channel-couplings.json +17 -4
  92. package/typescript/copy-overwrite/.claude/hooks/worktree-create.sh +3 -1
  93. package/typescript/copy-overwrite/.prettierignore +1 -0
  94. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +14 -54
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ /**
6
+ * Prove a version reached the npm registry, rather than trusting the exit code
7
+ * of the command that was supposed to put it there.
8
+ *
9
+ * Measured incident (CodySwannGT/lisa#3684): `v4.33.7` was tagged, a GitHub
10
+ * Release was cut for it, and a provenance statement was written to the sigstore
11
+ * transparency log — and `registry.npmjs.org/@codyswann/lisa/4.33.7` returned
12
+ * 404, and still did a day later. Three artifacts asserted the release had
13
+ * happened; the registry, the only one a consumer can install from, disagreed.
14
+ * Nothing retried it and nothing flagged it: the version counter moved on to
15
+ * 4.33.8, which makes the hole invisible in every downstream view.
16
+ *
17
+ * The publish itself had failed with `E401 Unauthorized` from npm's OIDC
18
+ * trusted-publisher exchange. There is no long-lived token in that path to
19
+ * rotate — the credential is minted per run — and the next release published
20
+ * fine 17 minutes later through the identical code path. So the failure is
21
+ * INTERMITTENT, and that is exactly why it needs a check: a permanently broken
22
+ * credential announces itself on the next release, while one that fails once in
23
+ * N produces a permanently skipped version with no signal at all.
24
+ *
25
+ * ## The exact-version endpoint, never `dist-tags.latest`
26
+ *
27
+ * `<registry>/<name>/<version>` answers about the version in hand.
28
+ * `<registry>/<name>` → `dist-tags.latest` answers about whichever version the
29
+ * registry currently considers newest, and it lags a successful publish by
30
+ * several minutes (CodySwannGT/lisa#3685). Verifying against `latest` would
31
+ * report a false 404 for a publish that actually landed, which trains everyone
32
+ * to ignore the check — the failure mode that ends with the check deleted.
33
+ *
34
+ * ## Three verdicts, because two would hide one of them
35
+ *
36
+ * - `published` — the registry served this exact version. Exit 0.
37
+ * - `missing` — the registry answered, and said 404. Exit 1.
38
+ * - `unprovable` — the registry could not be asked: transport error, 5xx, or a
39
+ * 200 whose body names a different version. Exit 1.
40
+ *
41
+ * `unprovable` blocks. It is not a pass, because nothing was proved, and a
42
+ * check that returns success for an input it never examined is the defect this
43
+ * script exists to catch, one layer up. It is not `missing` either: drafting a
44
+ * GitHub Release or alarming a human on a registry blip would be a false
45
+ * accusation, so the caller gets to tell the two apart and act differently.
46
+ * That distinction is the whole reason the verdict is a word on stdout and not
47
+ * just an exit code.
48
+ * @module scripts/check-npm-publish-landed
49
+ */
50
+ import process from "node:process";
51
+
52
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
53
+
54
+ /** Public npm registry, the default for every Lisa release. */
55
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
56
+
57
+ /** How many times to ask before settling on an answer. */
58
+ const DEFAULT_ATTEMPTS = 5;
59
+
60
+ /** Pause between attempts, in milliseconds. */
61
+ const DEFAULT_DELAY_MS = 3000;
62
+
63
+ /** The line a caller greps for. Never printed without a real verdict behind it. */
64
+ const VERDICT_PREFIX = "npm-publish-landed:";
65
+
66
+ /**
67
+ * The URL that answers "is this exact version on the registry".
68
+ *
69
+ * Deliberately the only URL this module ever builds. A packument read
70
+ * (`<registry>/<name>`) would carry `dist-tags`, and having that object in hand
71
+ * is all it takes for someone to start consulting it — so the shape that could
72
+ * regress is simply never fetched.
73
+ * @param {string} registry - Registry origin, no trailing slash.
74
+ * @param {string} name - Package name, scope included.
75
+ * @param {string} version - Exact version, no range syntax.
76
+ * @returns {string} The exact-version manifest URL.
77
+ */
78
+ export function exactVersionUrl(registry, name, version) {
79
+ // Trailing slashes trimmed without a regex: `/\/+$/` is a super-linear
80
+ // pattern the shipped ruleset rejects outright, and an origin arriving from
81
+ // configuration is exactly the kind of attacker-adjacent input that rule
82
+ // exists for.
83
+ let origin = registry;
84
+ while (origin.endsWith("/")) origin = origin.slice(0, -1);
85
+ return `${origin}/${name}/${version}`;
86
+ }
87
+
88
+ /**
89
+ * Ask the registry once.
90
+ * @param {string} url - The exact-version manifest URL.
91
+ * @param {typeof fetch} fetchImpl - Injected for tests.
92
+ * @param {string} version - The version the answer must name.
93
+ * @returns {Promise<{verdict: string, detail: string}>} One attempt's outcome.
94
+ */
95
+ async function askOnce(url, fetchImpl, version) {
96
+ let response;
97
+ try {
98
+ response = await fetchImpl(url, {
99
+ headers: { accept: "application/json" },
100
+ });
101
+ } catch (error) {
102
+ return { verdict: "unprovable", detail: `network: ${error.message}` };
103
+ }
104
+ if (response.status === 404) {
105
+ return { verdict: "missing", detail: "registry returned 404" };
106
+ }
107
+ if (!response.ok) {
108
+ return { verdict: "unprovable", detail: `HTTP ${response.status}` };
109
+ }
110
+ let manifest;
111
+ try {
112
+ manifest = await response.json();
113
+ } catch (error) {
114
+ return {
115
+ verdict: "unprovable",
116
+ detail: `unparseable body: ${error.message}`,
117
+ };
118
+ }
119
+ // A 200 that names a different version is not this version being present. The
120
+ // registry is not expected to do this; treating it as proof anyway is how a
121
+ // surprise becomes a silent pass.
122
+ if (manifest?.version !== version) {
123
+ return {
124
+ verdict: "unprovable",
125
+ detail: `HTTP 200 but the body names version ${String(manifest?.version)}`,
126
+ };
127
+ }
128
+ return { verdict: "published", detail: "registry served this exact version" };
129
+ }
130
+
131
+ /**
132
+ * Whether an attempt's verdict is worth another try.
133
+ *
134
+ * Both non-published verdicts are retried, for different reasons. `missing` is
135
+ * retried because a publish that just succeeded may not have propagated yet;
136
+ * `unprovable` because a 5xx or a dropped connection says nothing about the
137
+ * package. Neither is allowed to become `published` — a retry can only confirm
138
+ * or replace a non-answer, never upgrade one.
139
+ * @param {string} verdict - The attempt's verdict.
140
+ * @returns {boolean} Whether to ask again.
141
+ */
142
+ const worthRetrying = verdict => verdict !== "published";
143
+
144
+ /**
145
+ * Prove, or fail to prove, that one version is on the registry.
146
+ * @param {object} options - Everything the check needs.
147
+ * @param {string} options.packageName - Package name, scope included.
148
+ * @param {string} options.version - Exact version to look for.
149
+ * @param {string} [options.registry] - Registry origin.
150
+ * @param {number} [options.attempts] - How many times to ask.
151
+ * @param {number} [options.delayMs] - Pause between attempts.
152
+ * @param {typeof fetch} [options.fetchImpl] - Injected for tests.
153
+ * @param {(ms: number) => Promise<void>} [options.sleep] - Injected for tests.
154
+ * @returns {Promise<{verdict: string, detail: string, urls: string[]}>} Outcome.
155
+ */
156
+ export async function verifyPublish({
157
+ packageName,
158
+ version,
159
+ registry = DEFAULT_REGISTRY,
160
+ attempts = DEFAULT_ATTEMPTS,
161
+ delayMs = DEFAULT_DELAY_MS,
162
+ fetchImpl = fetch,
163
+ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
164
+ }) {
165
+ const url = exactVersionUrl(registry, packageName, version);
166
+ const urls = [];
167
+ let outcome = { verdict: "unprovable", detail: "no attempt was made" };
168
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
169
+ urls.push(url);
170
+ outcome = await askOnce(url, fetchImpl, version);
171
+ if (!worthRetrying(outcome.verdict)) break;
172
+ if (attempt < attempts) await sleep(delayMs);
173
+ }
174
+ return { ...outcome, urls };
175
+ }
176
+
177
+ /** Return a required CLI option, or exit naming it. */
178
+ function requiredOption(argv, name) {
179
+ const index = argv.indexOf(name);
180
+ const value = index === -1 ? undefined : argv[index + 1];
181
+ if (value === undefined || value.startsWith("--")) {
182
+ throw new Error(`Missing required option ${name}`);
183
+ }
184
+ return value;
185
+ }
186
+
187
+ /** Return an optional CLI option. */
188
+ function option(argv, name, fallback) {
189
+ const index = argv.indexOf(name);
190
+ const value = index === -1 ? undefined : argv[index + 1];
191
+ return value === undefined || value.startsWith("--") ? fallback : value;
192
+ }
193
+
194
+ /**
195
+ * Run the check and report, for a caller that is a workflow step.
196
+ * @param {string[]} argv - Arguments after the script path.
197
+ * @param {object} [injected] - Test seams forwarded to {@link verifyPublish}.
198
+ * @returns {Promise<number>} Process exit code.
199
+ */
200
+ export async function main(argv, injected = {}) {
201
+ const packageName = requiredOption(argv, "--package");
202
+ const version = requiredOption(argv, "--version");
203
+ const registry = option(argv, "--registry", DEFAULT_REGISTRY);
204
+ const attempts = Number(option(argv, "--attempts", String(DEFAULT_ATTEMPTS)));
205
+ const delayMs = Number(option(argv, "--delay-ms", String(DEFAULT_DELAY_MS)));
206
+
207
+ const { verdict, detail, urls } = await verifyPublish({
208
+ packageName,
209
+ version,
210
+ registry,
211
+ attempts,
212
+ delayMs,
213
+ ...injected,
214
+ });
215
+
216
+ process.stdout.write(
217
+ `${VERDICT_PREFIX} ${verdict} package=${packageName} version=${version}\n`
218
+ );
219
+ if (verdict === "published") {
220
+ process.stdout.write(`✅ ${packageName}@${version} is on ${registry}.\n`);
221
+ return 0;
222
+ }
223
+ const asked = urls[0] ?? exactVersionUrl(registry, packageName, version);
224
+ process.stderr.write(
225
+ verdict === "missing"
226
+ ? `❌ ${packageName}@${version} is NOT on the registry.\n` +
227
+ ` ${asked} → 404 after ${urls.length} attempt(s).\n` +
228
+ ` A tag, a GitHub Release and a provenance statement can all exist\n` +
229
+ ` for a version consumers cannot install. This is that state.\n`
230
+ : `❌ Could not prove ${packageName}@${version} reached the registry.\n` +
231
+ ` ${asked} → ${detail} (after ${urls.length} attempt(s)).\n` +
232
+ ` This is NOT a clean result and NOT a confirmed miss — nothing was\n` +
233
+ ` proved either way, so it blocks rather than reporting a verdict it\n` +
234
+ ` does not have.\n`
235
+ );
236
+ return 1;
237
+ }
238
+
239
+ /* c8 ignore start -- CLI wiring, exercised through main() in tests */
240
+ if (invokedAsScript(import.meta.url)) {
241
+ main(process.argv.slice(2))
242
+ .then(code => {
243
+ process.exitCode = code;
244
+ })
245
+ .catch(error => {
246
+ process.stderr.write(`❌ ${error.message}\n`);
247
+ process.exitCode = 1;
248
+ });
249
+ }
250
+ /* c8 ignore stop */
@@ -486,12 +486,10 @@ function killedVerdict(code, load) {
486
486
  * way this whole module can do harm.
487
487
  */
488
488
  const RESOURCE_REFUSAL_PATTERNS = Object.freeze([
489
- /\bsetpgid\s*\([^)]*\):\s*Operation not permitted/,
489
+ /\bchild setpgid\s*\(\d+\s+to\s+\d+\):\s*Operation not permitted/,
490
490
  /\bfork:\s*Resource temporarily unavailable/,
491
- /\bposix_spawn\b[^\n]*\b(?:EAGAIN|ENOMEM)\b/,
492
- /\bspawn\b[^\n]*\bEAGAIN\b/,
493
- /\bEMFILE\b|\bToo many open files\b/,
494
- /\bENOMEM\b|\bCannot allocate memory\b/,
491
+ /\bposix_spawn\b[^\n]*\b(?:EAGAIN|ENOMEM|EMFILE)\b/,
492
+ /\bspawn\b[^\n]*\b(?:EAGAIN|ENOMEM|EMFILE)\b/,
495
493
  ]);
496
494
 
497
495
  /**
@@ -1435,8 +1435,20 @@ EXEC_WRAPPERS = {
1435
1435
  }
1436
1436
 
1437
1437
  # Interpreter options that mean "no script file follows": a command string or a
1438
- # module name, both of which the guard reads by other means or not at all.
1439
- NO_SCRIPT_OPTIONS = {"-c", "-e", "-m", "--eval", "--command", "--module"}
1438
+ # module name, both of which the guard reads by other means or not at all. These
1439
+ # are interpreter-specific on purpose. `bash -e create.sh` and `sh -m create.sh`
1440
+ # still execute the following file; treating Python and Node flags as universal
1441
+ # silently stopped the scan before those shell operands.
1442
+ NO_SCRIPT_OPTIONS = {
1443
+ "bun": {"-e", "--eval"},
1444
+ "node": {"-e", "--eval"},
1445
+ "nodejs": {"-e", "--eval"},
1446
+ "perl": {"-e"},
1447
+ "php": {"-r"},
1448
+ "python": {"-c", "-m", "--command", "--module"},
1449
+ "python3": {"-c", "-m", "--command", "--module"},
1450
+ "ruby": {"-e"},
1451
+ }
1440
1452
 
1441
1453
  # Programs that READ their operands and never execute them as programs.
1442
1454
  #
@@ -1572,7 +1584,12 @@ def executed_operand(argv):
1572
1584
  if argument.startswith("-"):
1573
1585
  # A command string or a module name; no script file follows.
1574
1586
  head = argument.split("=", 1)[0]
1575
- if argument in NO_SCRIPT_OPTIONS or head in NO_SCRIPT_OPTIONS:
1587
+ if program in {"bash", "dash", "ksh", "sh", "zsh"}:
1588
+ # Shell `-c` may be clustered (`-ec`), while `-e` and `-m` are
1589
+ # ordinary shell options and do not consume the script path.
1590
+ if not head.startswith("--") and "c" in head[1:]:
1591
+ return None
1592
+ elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1576
1593
  return None
1577
1594
  continue
1578
1595
  return argument
@@ -93,7 +93,7 @@
93
93
  # from mine", and guessing "behind" is how a fork's stronger guard gets silently
94
94
  # replaced by a weaker upstream one. Add a name here in the same commit that
95
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
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, direct-script-command-reach, analyzer-failure-visible
97
97
  set -euo pipefail
98
98
 
99
99
  input="$(cat)"
@@ -610,7 +610,9 @@ def command_word(statement):
610
610
  statement: One statement's tokens.
611
611
 
612
612
  Returns:
613
- A pair (program, args); (None, []) when no program is named.
613
+ A triple (token, program, args); (None, None, []) when no program is
614
+ named. The raw token is retained because `./edit.sh` has no interpreter:
615
+ its command word is itself the executed script.
614
616
  """
615
617
  index = 0
616
618
  while index < len(statement):
@@ -620,7 +622,7 @@ def command_word(statement):
620
622
  continue
621
623
  program = token.rsplit("/", 1)[-1]
622
624
  if program not in FOLLOW_WRAPPERS:
623
- return (program, statement[index + 1 :])
625
+ return (token, program, statement[index + 1 :])
624
626
  separate, positional = FOLLOW_WRAPPERS[program]
625
627
  index += 1
626
628
  while index < len(statement) and statement[index].startswith("-"):
@@ -630,7 +632,7 @@ def command_word(statement):
630
632
  break
631
633
  index += 2 if option in separate else 1
632
634
  index += positional
633
- return (None, [])
635
+ return (None, None, [])
634
636
 
635
637
 
636
638
  def shell_script_operand(args):
@@ -677,7 +679,7 @@ def write_targets(statement):
677
679
  Candidate paths this statement writes.
678
680
  """
679
681
  found = []
680
- program, args = command_word(statement)
682
+ _token, program, args = command_word(statement)
681
683
  for index, token in enumerate(statement):
682
684
  if token in REDIRECT_OUT and index + 1 < len(statement):
683
685
  found.append(statement[index + 1])
@@ -700,7 +702,7 @@ def executed_script(statement):
700
702
  Returns:
701
703
  The operand token naming a script that will run, or None.
702
704
  """
703
- program, args = command_word(statement)
705
+ token, program, args = command_word(statement)
704
706
  if program is None:
705
707
  return None
706
708
  if program in SHELL_PROGRAMS:
@@ -711,6 +713,12 @@ def executed_script(statement):
711
713
  return shell_script_operand(args)
712
714
  if program in SOURCE_BUILTINS:
713
715
  return args[0] if args else None
716
+ # A script in command position executes through its shebang. This also
717
+ # covers a direct path after wrappers such as `sudo ./edit.sh`; `command_word`
718
+ # deliberately keeps the raw token while using its basename for wrapper and
719
+ # shell classification.
720
+ if resolve(token) is not None:
721
+ return token
714
722
  return None
715
723
 
716
724
 
@@ -801,10 +809,27 @@ for target in targets:
801
809
  if cleaned:
802
810
  print(cleaned)
803
811
  PY
804
- write_targets="$(printf '%s' "$analyzer" |
812
+ analyzer_stderr="$(mktemp "${TMPDIR:-/tmp}/lisa-managed-edit-analyzer.XXXXXX")" || {
813
+ printf 'block-managed-file-edits: could not allocate analyzer stderr capture; Bash write protection is NOT active\n' >&2
814
+ exit 0
815
+ }
816
+ if write_targets="$(printf '%s' "$analyzer" |
805
817
  MANAGED_EDIT_COMMAND="$command_str" \
806
818
  MANAGED_EDIT_PROJECT="$project_root" \
807
- python3 - 2>/dev/null || true)"
819
+ python3 - 2>"$analyzer_stderr")"; then
820
+ analyzer_status=0
821
+ else
822
+ analyzer_status=$?
823
+ fi
824
+ if [ "$analyzer_status" -ne 0 ]; then
825
+ printf 'block-managed-file-edits: Bash analyzer failed (exit %s); Bash write protection is NOT active\n' "$analyzer_status" >&2
826
+ while IFS= read -r analyzer_line; do
827
+ [ -n "$analyzer_line" ] && printf ' python3: %s\n' "$analyzer_line" >&2
828
+ done <"$analyzer_stderr"
829
+ rm -f "$analyzer_stderr"
830
+ exit 0
831
+ fi
832
+ rm -f "$analyzer_stderr"
808
833
  while IFS= read -r token; do
809
834
  [ -n "$token" ] || continue
810
835
  if source_path="$(managed_source "$token")"; then
@@ -36,7 +36,7 @@
36
36
  # Add a name here in the same commit that closes a vector. A hardening that
37
37
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
38
38
  # review time instead of a named capability.
39
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position
39
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
40
40
  #
41
41
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
42
42
  # commit-message prose while still catching quoted real argv values such as
@@ -268,8 +268,38 @@ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
268
268
 
269
269
  # env's single-letter options, by whether the rest of a cluster is more
270
270
  # options or the option's value.
271
+ #
272
+ # BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
273
+ # in neither set falls through to `ambiguous`, which this guard treats as
274
+ # suspicious. That default is correct and stays — an unknown option genuinely
275
+ # could take a value and swallow the command name. What is wrong is a
276
+ # well-known option being unknown, and on a macOS fleet `env -P` is exactly
277
+ # that: BSD's "search altpath for the utility", refused on every ordinary
278
+ # command it prefixes.
279
+ #
280
+ # Measured, one option at a time, against the guard before this change. BSD's
281
+ # complete short set is `0 i v u C P S`, from the synopsis in env(1):
282
+ #
283
+ # allowed -0 -i -v -u NAME -C DIR already covered
284
+ # BLOCKED -P altpath the defect, and the ONLY one
285
+ # BLOCKED -S string correct: split-string reparses
286
+ # an opaque payload, so what it
287
+ # runs cannot be proven here
288
+ # BLOCKED -Z -Q correct: unknown stays suspicious
289
+ #
290
+ # So the population is one letter, not a class. The two platforms' short
291
+ # options coincide everywhere else, which is why "detect the platform and
292
+ # choose a table" would be machinery for a single character — and would fail
293
+ # on a machine running GNU coreutils under a BSD userland, or the reverse.
294
+ # Recognising the union is both smaller and more robust.
295
+ #
296
+ # Adding a letter here is a WEAKENING, so it needs the same evidence as any
297
+ # other: ten bypass shapes routed through `-P` — long flag, attached value,
298
+ # short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
299
+ # a nested `-c` shell, an executed script, and `-P` swallowing the command
300
+ # word — were all refused before this change and are all still refused after.
271
301
  ENV_SHORT_NO_VALUE = frozenset("i0v")
272
- ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
302
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
273
303
 
274
304
  # Builtins and wrappers that run whatever FOLLOWS them without changing what
275
305
  # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
@@ -980,7 +1010,7 @@ def resolve_script(token):
980
1010
  """
981
1011
  if COMPUTED_VALUE.search(token):
982
1012
  return (None, "a computed path the guard cannot resolve before the shell does")
983
- text = token.strip().strip("'\"")
1013
+ text = os.path.expanduser(token.strip().strip("'\""))
984
1014
  # `bash -` and a bare `-` read the script from stdin; there is no file.
985
1015
  if not text or text == "-":
986
1016
  return (None, None)
@@ -1002,6 +1032,48 @@ def resolve_script(token):
1002
1032
  return (None, "a script that does not exist or cannot be read")
1003
1033
 
1004
1034
 
1035
+ SHELL_COMMAND_STRING = object()
1036
+ SHELL_STDIN = object()
1037
+
1038
+
1039
+ def shell_execution_operand(args):
1040
+ """The file a shell runs, or a sentinel for `-c` and stdin modes.
1041
+
1042
+ `shell_script_operand` intentionally collapses both cases to None for the
1043
+ nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
1044
+ its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
1045
+ stdin and must remain linked to a following `< file` separator.
1046
+ """
1047
+ index = 0
1048
+ opaque_option_seen = False
1049
+ while index < len(args):
1050
+ token = args[index]
1051
+ if token == "--":
1052
+ index += 1
1053
+ break
1054
+ if not token.startswith(("-", "+")):
1055
+ if not opaque_option_seen:
1056
+ break
1057
+ index += 1
1058
+ opaque_option_seen = False
1059
+ continue
1060
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
1061
+ index += 2
1062
+ continue
1063
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
1064
+ index += 1
1065
+ continue
1066
+ if token.startswith("--"):
1067
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
1068
+ opaque_option_seen = True
1069
+ index += 1
1070
+ continue
1071
+ if "c" in token[1:]:
1072
+ return SHELL_COMMAND_STRING
1073
+ index += 1
1074
+ return args[index] if index < len(args) else SHELL_STDIN
1075
+
1076
+
1005
1077
  def executed_scripts(tokens):
1006
1078
  """The files a command line will RUN, and the executions it cannot follow.
1007
1079
 
@@ -1036,9 +1108,12 @@ def executed_scripts(tokens):
1036
1108
  if name in SOURCE_BUILTINS:
1037
1109
  operand = arguments[0] if arguments else None
1038
1110
  elif name in SHELL_PROGRAMS:
1039
- operand = shell_script_operand(arguments)
1040
- if operand is None and not arguments:
1111
+ operand = shell_execution_operand(arguments)
1112
+ if operand is SHELL_STDIN:
1041
1113
  previous_shell_awaiting_stdin = True
1114
+ continue
1115
+ if operand is SHELL_COMMAND_STRING:
1116
+ continue
1042
1117
  else:
1043
1118
  continue
1044
1119
  if operand is None:
@@ -3475,7 +3475,7 @@ function recordDeployRole(ref, before, role) {
3475
3475
  * that wrongly did.
3476
3476
  * @param {string} ref Canonical work-item reference.
3477
3477
  * @param {object} contract The resolved tracker contract.
3478
- * @returns {{applied: boolean, merged: number[], report: string, terminal: string|null}}
3478
+ * @returns {{applied: boolean, bases: string, merged: number[], report: string, terminal: string|null}}
3479
3479
  * What was applied, and why.
3480
3480
  */
3481
3481
  function completeGithubWorkItem(ref, contract) {
@@ -10,7 +10,7 @@
10
10
  # `git -c "core.hooksPath=/dev/null"`.
11
11
  #
12
12
  # Capability names below are the canonical guard's, kept identical on every agent.
13
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position
13
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
14
14
  set -euo pipefail
15
15
 
16
16
  input="$(cat 2>/dev/null || true)"
@@ -171,8 +171,38 @@ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
171
171
 
172
172
  # env's single-letter options, by whether the rest of a cluster is more
173
173
  # options or the option's value.
174
+ #
175
+ # BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
176
+ # in neither set falls through to `ambiguous`, which this guard treats as
177
+ # suspicious. That default is correct and stays — an unknown option genuinely
178
+ # could take a value and swallow the command name. What is wrong is a
179
+ # well-known option being unknown, and on a macOS fleet `env -P` is exactly
180
+ # that: BSD's "search altpath for the utility", refused on every ordinary
181
+ # command it prefixes.
182
+ #
183
+ # Measured, one option at a time, against the guard before this change. BSD's
184
+ # complete short set is `0 i v u C P S`, from the synopsis in env(1):
185
+ #
186
+ # allowed -0 -i -v -u NAME -C DIR already covered
187
+ # BLOCKED -P altpath the defect, and the ONLY one
188
+ # BLOCKED -S string correct: split-string reparses
189
+ # an opaque payload, so what it
190
+ # runs cannot be proven here
191
+ # BLOCKED -Z -Q correct: unknown stays suspicious
192
+ #
193
+ # So the population is one letter, not a class. The two platforms' short
194
+ # options coincide everywhere else, which is why "detect the platform and
195
+ # choose a table" would be machinery for a single character — and would fail
196
+ # on a machine running GNU coreutils under a BSD userland, or the reverse.
197
+ # Recognising the union is both smaller and more robust.
198
+ #
199
+ # Adding a letter here is a WEAKENING, so it needs the same evidence as any
200
+ # other: ten bypass shapes routed through `-P` — long flag, attached value,
201
+ # short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
202
+ # a nested `-c` shell, an executed script, and `-P` swallowing the command
203
+ # word — were all refused before this change and are all still refused after.
174
204
  ENV_SHORT_NO_VALUE = frozenset("i0v")
175
- ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
205
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
176
206
 
177
207
  # Builtins and wrappers that run whatever FOLLOWS them without changing what
178
208
  # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
@@ -883,7 +913,7 @@ def resolve_script(token):
883
913
  """
884
914
  if COMPUTED_VALUE.search(token):
885
915
  return (None, "a computed path the guard cannot resolve before the shell does")
886
- text = token.strip().strip("'\"")
916
+ text = os.path.expanduser(token.strip().strip("'\""))
887
917
  # `bash -` and a bare `-` read the script from stdin; there is no file.
888
918
  if not text or text == "-":
889
919
  return (None, None)
@@ -905,6 +935,48 @@ def resolve_script(token):
905
935
  return (None, "a script that does not exist or cannot be read")
906
936
 
907
937
 
938
+ SHELL_COMMAND_STRING = object()
939
+ SHELL_STDIN = object()
940
+
941
+
942
+ def shell_execution_operand(args):
943
+ """The file a shell runs, or a sentinel for `-c` and stdin modes.
944
+
945
+ `shell_script_operand` intentionally collapses both cases to None for the
946
+ nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
947
+ its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
948
+ stdin and must remain linked to a following `< file` separator.
949
+ """
950
+ index = 0
951
+ opaque_option_seen = False
952
+ while index < len(args):
953
+ token = args[index]
954
+ if token == "--":
955
+ index += 1
956
+ break
957
+ if not token.startswith(("-", "+")):
958
+ if not opaque_option_seen:
959
+ break
960
+ index += 1
961
+ opaque_option_seen = False
962
+ continue
963
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
964
+ index += 2
965
+ continue
966
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
967
+ index += 1
968
+ continue
969
+ if token.startswith("--"):
970
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
971
+ opaque_option_seen = True
972
+ index += 1
973
+ continue
974
+ if "c" in token[1:]:
975
+ return SHELL_COMMAND_STRING
976
+ index += 1
977
+ return args[index] if index < len(args) else SHELL_STDIN
978
+
979
+
908
980
  def executed_scripts(tokens):
909
981
  """The files a command line will RUN, and the executions it cannot follow.
910
982
 
@@ -939,9 +1011,12 @@ def executed_scripts(tokens):
939
1011
  if name in SOURCE_BUILTINS:
940
1012
  operand = arguments[0] if arguments else None
941
1013
  elif name in SHELL_PROGRAMS:
942
- operand = shell_script_operand(arguments)
943
- if operand is None and not arguments:
1014
+ operand = shell_execution_operand(arguments)
1015
+ if operand is SHELL_STDIN:
944
1016
  previous_shell_awaiting_stdin = True
1017
+ continue
1018
+ if operand is SHELL_COMMAND_STRING:
1019
+ continue
945
1020
  else:
946
1021
  continue
947
1022
  if operand is None:
@@ -1 +1 @@
1
- {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA67BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAg2BjC,CAAC"}
1
+ {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA28BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA82BjC,CAAC"}