@codyswann/lisa 4.35.0 → 4.35.1

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/all/copy-overwrite/scripts/check-npm-publish-landed.mjs +49 -26
  2. package/all/copy-overwrite/scripts/lib/kill-marks.mjs +1 -0
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +49 -10
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +42 -4
  5. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +33 -12
  6. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +9 -4
  7. package/all/copy-overwrite/scripts/lisa-work-item.mjs +9 -6
  8. package/dist/codex/scripts/_extract-edit-paths.sh +4 -0
  9. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.js +20 -0
  11. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  12. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  13. package/dist/core/upstream-evidence-manifest.js +10 -10
  14. package/dist/migrations/ensure-lisa-postinstall.d.ts +6 -37
  15. package/dist/migrations/ensure-lisa-postinstall.d.ts.map +1 -1
  16. package/dist/migrations/ensure-lisa-postinstall.js +20 -67
  17. package/dist/migrations/ensure-lisa-postinstall.js.map +1 -1
  18. package/dist/opencode/plugin-templates/parity-safety-net.sh +33 -12
  19. package/package.json +4 -4
  20. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa/hooks/block-direct-issue-create.sh +49 -10
  23. package/plugins/lisa/hooks/block-managed-file-edits.sh +42 -4
  24. package/plugins/lisa/hooks/parity-safety-net.sh +33 -12
  25. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +49 -10
  26. package/plugins/lisa-agy/hooks/parity-safety-net.sh +33 -12
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  31. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +49 -10
  35. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +42 -4
  36. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +33 -12
  37. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +49 -10
  39. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +42 -4
  40. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +33 -12
  41. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo-agy/plugin.json +1 -1
  44. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  64. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails-agy/plugin.json +1 -1
  69. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  74. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  79. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/src/base/hooks/block-direct-issue-create.sh +49 -10
  82. package/plugins/src/base/hooks/block-managed-file-edits.sh +42 -4
  83. package/plugins/src/base/hooks/parity-safety-net.sh +33 -12
@@ -60,6 +60,9 @@ const DEFAULT_ATTEMPTS = 5;
60
60
  /** Pause between attempts, in milliseconds. */
61
61
  const DEFAULT_DELAY_MS = 3000;
62
62
 
63
+ /** Maximum wall time for one registry attempt, including body parsing. */
64
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000;
65
+
63
66
  /** The line a caller greps for. Never printed without a real verdict behind it. */
64
67
  const VERDICT_PREFIX = "npm-publish-landed:";
65
68
 
@@ -90,42 +93,52 @@ export function exactVersionUrl(registry, name, version) {
90
93
  * @param {string} url - The exact-version manifest URL.
91
94
  * @param {typeof fetch} fetchImpl - Injected for tests.
92
95
  * @param {string} version - The version the answer must name.
96
+ * @param {object} timeout - Per-attempt deadline dependencies.
97
+ * @param {number} timeout.attemptTimeoutMs - Deadline in milliseconds.
98
+ * @param {() => AbortController} timeout.createAbortController - Controller factory.
99
+ * @param {typeof setTimeout} timeout.setAttemptTimer - Timer scheduler.
100
+ * @param {typeof clearTimeout} timeout.clearAttemptTimer - Timer clearer.
93
101
  * @returns {Promise<{verdict: string, detail: string}>} One attempt's outcome.
94
102
  */
95
- async function askOnce(url, fetchImpl, version) {
96
- let response;
103
+ async function askOnce(url, fetchImpl, version, timeout) {
104
+ const controller = timeout.createAbortController();
105
+ const timer = timeout.setAttemptTimer(
106
+ () => controller.abort(),
107
+ timeout.attemptTimeoutMs
108
+ );
97
109
  try {
98
- response = await fetchImpl(url, {
110
+ const response = await fetchImpl(url, {
99
111
  headers: { accept: "application/json" },
112
+ signal: controller.signal,
100
113
  });
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
+ if (response.status === 404) {
115
+ return { verdict: "missing", detail: "registry returned 404" };
116
+ }
117
+ if (!response.ok) {
118
+ return { verdict: "unprovable", detail: `HTTP ${response.status}` };
119
+ }
120
+ const manifest = await response.json();
121
+ // A 200 that names a different version is not this version being present.
122
+ if (manifest?.version !== version) {
123
+ return {
124
+ verdict: "unprovable",
125
+ detail: `HTTP 200 but the body names version ${String(manifest?.version)}`,
126
+ };
127
+ }
114
128
  return {
115
- verdict: "unprovable",
116
- detail: `unparseable body: ${error.message}`,
129
+ verdict: "published",
130
+ detail: "registry served this exact version",
117
131
  };
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) {
132
+ } catch (error) {
123
133
  return {
124
134
  verdict: "unprovable",
125
- detail: `HTTP 200 but the body names version ${String(manifest?.version)}`,
135
+ detail: controller.signal.aborted
136
+ ? `attempt exceeded ${String(timeout.attemptTimeoutMs)}ms deadline`
137
+ : `network or body: ${error.message}`,
126
138
  };
139
+ } finally {
140
+ timeout.clearAttemptTimer(timer);
127
141
  }
128
- return { verdict: "published", detail: "registry served this exact version" };
129
142
  }
130
143
 
131
144
  /**
@@ -149,6 +162,7 @@ const worthRetrying = verdict => verdict !== "published";
149
162
  * @param {string} [options.registry] - Registry origin.
150
163
  * @param {number} [options.attempts] - How many times to ask.
151
164
  * @param {number} [options.delayMs] - Pause between attempts.
165
+ * @param {number} [options.attemptTimeoutMs] - Deadline for each attempt.
152
166
  * @param {typeof fetch} [options.fetchImpl] - Injected for tests.
153
167
  * @param {(ms: number) => Promise<void>} [options.sleep] - Injected for tests.
154
168
  * @returns {Promise<{verdict: string, detail: string, urls: string[]}>} Outcome.
@@ -159,15 +173,24 @@ export async function verifyPublish({
159
173
  registry = DEFAULT_REGISTRY,
160
174
  attempts = DEFAULT_ATTEMPTS,
161
175
  delayMs = DEFAULT_DELAY_MS,
176
+ attemptTimeoutMs = DEFAULT_ATTEMPT_TIMEOUT_MS,
162
177
  fetchImpl = fetch,
163
178
  sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
179
+ createAbortController = () => new AbortController(),
180
+ setAttemptTimer = setTimeout,
181
+ clearAttemptTimer = clearTimeout,
164
182
  }) {
165
183
  const url = exactVersionUrl(registry, packageName, version);
166
184
  const urls = [];
167
185
  let outcome = { verdict: "unprovable", detail: "no attempt was made" };
168
186
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
169
187
  urls.push(url);
170
- outcome = await askOnce(url, fetchImpl, version);
188
+ outcome = await askOnce(url, fetchImpl, version, {
189
+ attemptTimeoutMs,
190
+ createAbortController,
191
+ setAttemptTimer,
192
+ clearAttemptTimer,
193
+ });
171
194
  if (!worthRetrying(outcome.verdict)) break;
172
195
  if (attempt < attempts) await sleep(delayMs);
173
196
  }
@@ -164,6 +164,7 @@ export function recentKillMarks(deps = {}) {
164
164
  })
165
165
  .filter(mark => mark !== null && mark.schema === 1)
166
166
  .filter(mark => Number.isFinite(mark.at))
167
+ .filter(mark => mark.at <= now)
167
168
  .filter(mark => now - mark.at <= KILL_MARK_RETENTION_MS)
168
169
  .sort((left, right) => right.at - left.at);
169
170
  } catch {
@@ -1429,7 +1429,11 @@ EXEC_WRAPPERS = {
1429
1429
  "nohup": (frozenset(), 0),
1430
1430
  "setsid": (frozenset(), 0),
1431
1431
  "stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
1432
- "sudo": (frozenset({"-u", "--user", "-g", "--group", "-C", "--close-from"}), 0),
1432
+ "sudo": (frozenset({
1433
+ "-u", "--user", "-g", "--group", "-C", "--close-from",
1434
+ "-p", "--prompt", "-h", "--host", "-r", "--role",
1435
+ "-t", "--type", "-U", "--other-user",
1436
+ }), 0),
1433
1437
  "time": (frozenset(), 0),
1434
1438
  "timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
1435
1439
  }
@@ -1450,6 +1454,31 @@ NO_SCRIPT_OPTIONS = {
1450
1454
  "ruby": {"-e"},
1451
1455
  }
1452
1456
 
1457
+ # Interpreter switches whose value is a separate token, followed by the real
1458
+ # script operand. Treating the value as the script makes the guard inspect a
1459
+ # preload or option value and never reach what the interpreter executes.
1460
+ INTERPRETER_VALUE_OPTIONS = {
1461
+ "bash": {"-o", "--option"},
1462
+ "dash": {"-o"},
1463
+ "deno": {"-c", "--config"},
1464
+ "ksh": {"-o"},
1465
+ "node": {"-r", "--require"},
1466
+ "nodejs": {"-r", "--require"},
1467
+ "sh": {"-o"},
1468
+ "zsh": {"-o"},
1469
+ }
1470
+
1471
+ # Runtime subcommands that introduce the script path rather than name it.
1472
+ INTERPRETER_SUBCOMMANDS = {
1473
+ "bun": {"run"},
1474
+ "deno": {"run"},
1475
+ }
1476
+
1477
+
1478
+ def is_assignment_word(token):
1479
+ """Whether token is a POSIX shell assignment in command-prefix position."""
1480
+ return re.match(r"^[A-Za-z_][A-Za-z0-9_]*\+?=", token) is not None
1481
+
1453
1482
  # Programs that READ their operands and never execute them as programs.
1454
1483
  #
1455
1484
  # This is an allowlist of readers rather than the inverse, and the asymmetry is
@@ -1504,7 +1533,7 @@ def executing_command(argv):
1504
1533
  index = 0
1505
1534
  while index < len(argv):
1506
1535
  token = argv[index]
1507
- if "=" in token and not token.startswith(("=", "-")):
1536
+ if is_assignment_word(token):
1508
1537
  index += 1
1509
1538
  continue
1510
1539
  program = basename(token)
@@ -1571,16 +1600,20 @@ def executed_operand(argv):
1571
1600
  # shebang — `./create.sh` names no interpreter and runs all the same.
1572
1601
  if program not in EXECUTING_INTERPRETERS and resolve_operand(token) is not None:
1573
1602
  return token
1574
- # `bash < create.sh` runs that file as surely as `bash create.sh` does, and
1575
- # `<` is not a segment boundary, so the pair survives in one argv.
1576
- for index, argument in enumerate(args):
1577
- if argument == "<" and index + 1 < len(args):
1578
- return args[index + 1]
1579
1603
  # Reached by a known interpreter AND by a program this parser cannot name.
1580
1604
  # The unknown case is followed on purpose — see READ_ONLY_PROGRAMS.
1581
- for argument in args:
1582
- if argument == "--":
1605
+ redirected = None
1606
+ index = 0
1607
+ while index < len(args):
1608
+ argument = args[index]
1609
+ if argument == "<":
1610
+ if index + 1 < len(args):
1611
+ redirected = args[index + 1]
1612
+ index += 2
1583
1613
  continue
1614
+ if argument == "--":
1615
+ index += 1
1616
+ return args[index] if index < len(args) else redirected
1584
1617
  if argument.startswith("-"):
1585
1618
  # A command string or a module name; no script file follows.
1586
1619
  head = argument.split("=", 1)[0]
@@ -1591,9 +1624,15 @@ def executed_operand(argv):
1591
1624
  return None
1592
1625
  elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1593
1626
  return None
1627
+ takes_value = head in INTERPRETER_VALUE_OPTIONS.get(program, set())
1628
+ index += 2 if takes_value and "=" not in argument else 1
1629
+ continue
1630
+ if argument in INTERPRETER_SUBCOMMANDS.get(program, set()):
1631
+ index += 1
1594
1632
  continue
1595
1633
  return argument
1596
- return None
1634
+ # `bash < create.sh` runs stdin only when no explicit script was named.
1635
+ return redirected
1597
1636
 
1598
1637
 
1599
1638
  def file_operands(argv):
@@ -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, direct-script-command-reach, analyzer-failure-visible
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, apply-patch-parse-failure-visible, apply-patch-move-target, shell-command-string-reach
97
97
  set -euo pipefail
98
98
 
99
99
  input="$(cat)"
@@ -402,6 +402,7 @@ case "$tool_name" in
402
402
  # *** Add File: <path>
403
403
  # *** Update File: <path>
404
404
  # *** Delete File: <path>
405
+ # *** Move to: <path>
405
406
  #
406
407
  # So every header is walked and each target classified; one managed
407
408
  # target anywhere in a multi-file patch refuses the whole call, because
@@ -418,14 +419,25 @@ case "$tool_name" in
418
419
  # legitimately cannot be one file. What is NOT restated is the
419
420
  # classification: `managed_source` and `refuse` are the single copy here,
420
421
  # exactly as for every other arm.
421
- patch_text="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
422
+ if ! patch_text="$(printf '%s' "$input" | jq -er '
423
+ if ((.tool_input.command? // "") | type) == "string"
424
+ then (.tool_input.command? // "")
425
+ else error("tool_input.command must be a string")
426
+ end')"; then
427
+ printf 'block-managed-file-edits: could not parse the apply_patch command; managed-file protection refused the edit\n' >&2
428
+ exit 2
429
+ fi
422
430
  [ -n "$patch_text" ] || exit 0
423
431
  while IFS= read -r patch_line; do
424
432
  case "$patch_line" in
425
- "*** Add File: "* | "*** Update File: "* | "*** Delete File: "*) ;;
433
+ "*** Add File: "* | "*** Update File: "* | "*** Delete File: "*)
434
+ candidate="${patch_line#*File: }"
435
+ ;;
436
+ "*** Move to: "*)
437
+ candidate="${patch_line#*** Move to: }"
438
+ ;;
426
439
  *) continue ;;
427
440
  esac
428
- candidate="${patch_line#*File: }"
429
441
  [ -n "$candidate" ] || continue
430
442
  if source_path="$(managed_source "$candidate")"; then
431
443
  refuse "$candidate" "$source_path" "$(relative_path "$candidate")"
@@ -715,6 +727,29 @@ def shell_script_operand(args):
715
727
  return None
716
728
 
717
729
 
730
+ def shell_command_string(statement):
731
+ """The inline command a shell executes with `-c`, or None.
732
+
733
+ Args:
734
+ statement: One statement's tokens.
735
+
736
+ Returns:
737
+ The command string operand, or None when this is not a shell `-c` call.
738
+ """
739
+ _token, program, args = command_word(statement)
740
+ if program not in SHELL_PROGRAMS:
741
+ return None
742
+ for index, token in enumerate(args):
743
+ if token == "--":
744
+ return None
745
+ if token.startswith("-") and not token.startswith("--") and "c" in token[1:]:
746
+ command_index = index + 1
747
+ if command_index < len(args) and args[command_index] == "--":
748
+ command_index += 1
749
+ return args[command_index] if command_index < len(args) else None
750
+ return None
751
+
752
+
718
753
  def write_targets(statement):
719
754
  """Paths this statement WRITES.
720
755
 
@@ -837,6 +872,9 @@ def collect(text, depth, seen, out):
837
872
  if not statement:
838
873
  continue
839
874
  out.extend(write_targets(statement))
875
+ nested = shell_command_string(statement)
876
+ if nested is not None and depth < FOLLOW_MAX_DEPTH:
877
+ collect(nested, depth + 1, seen, out)
840
878
  operand = executed_script(statement)
841
879
  if operand is None:
842
880
  continue
@@ -182,12 +182,14 @@ EOF
182
182
  # The patch file is per-worktree: `mktemp` names it, so nothing has to invent a
183
183
  # filename, and it lives in this agent's own TMPDIR where no sibling can reach
184
184
  # it. Untracked files are unaffected by both refused operations, so `git diff`
185
- # captures everything at risk.
185
+ # captures everything at risk. `HEAD` includes staged and unstaged changes, and
186
+ # `--binary` keeps binary edits recoverable rather than emitting a text-only
187
+ # placeholder that cannot be applied.
186
188
  PRESERVE_GUIDANCE="$(
187
189
  cat <<'EOF'
188
190
  Preserve the work first, per-worktree:
189
191
 
190
- git diff [-- <path>] > "$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX.patch")"
192
+ git diff --binary HEAD [-- <path>] > "$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX.patch")"
191
193
 
192
194
  and restore it later with `git apply <that file>`.
193
195
 
@@ -447,6 +449,7 @@ follow_refuse() {
447
449
  # so peak process count never rises. The latency was the whole problem.
448
450
  follow_unwrapped=""
449
451
  follow_operand_index=0
452
+ follow_located=""
450
453
 
451
454
  # Strip substitution, quote and alias wrappers off one token. A sibling of
452
455
  # strip_subst_wrappers() below rather than a call to it: that one is defined
@@ -571,6 +574,7 @@ follow_add_base() {
571
574
  # this hook cannot evaluate.
572
575
  follow_locate() {
573
576
  local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
577
+ follow_located=""
574
578
  tmp="${tmp%/}"
575
579
  [ -n "$tmp" ] || tmp="/tmp"
576
580
  # The tilde arms below are LITERAL text: this is the unexpanded spelling the
@@ -592,14 +596,14 @@ follow_locate() {
592
596
  "" | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*) return 1 ;;
593
597
  /*)
594
598
  [ -f "$token" ] && [ -r "$token" ] || return 1
595
- printf '%s' "$token"
599
+ follow_located="$token"
596
600
  return 0
597
601
  ;;
598
602
  esac
599
603
  while IFS= read -r base; do
600
604
  [ -n "$base" ] || continue
601
605
  if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
602
- printf '%s' "$base/$token"
606
+ follow_located="$base/$token"
603
607
  return 0
604
608
  fi
605
609
  done <<<"$PWD"$'\n'"$proj$follow_bases"
@@ -644,12 +648,13 @@ follow_take() {
644
648
  follow_target() {
645
649
  local token="$1" depth="$2" path=""
646
650
  [ -n "$token" ] || return 0
647
- if ! path="$(follow_locate "$token")"; then
651
+ if ! follow_locate "$token"; then
648
652
  if [ "$depth" -eq 0 ]; then
649
653
  follow_refuse "$token" "no readable file could be resolved for it"
650
654
  fi
651
655
  return 0
652
656
  fi
657
+ path="$follow_located"
653
658
  follow_take "$path" "$depth"
654
659
  }
655
660
 
@@ -670,11 +675,18 @@ follow_target() {
670
675
  # Accepted residual: a shebang script whose relative path resolves only from a
671
676
  # working directory this hook does not share is not followed.
672
677
  follow_direct() {
673
- local token="$1" depth="$2" path="" first=""
674
- if ! path="$(follow_locate "$token")"; then
678
+ local token="$1" depth="$2" path="" first="" raw="" char="" k=0
679
+ local LC_ALL=C
680
+ if ! follow_locate "$token"; then
675
681
  return 0
676
682
  fi
677
- first="$(LC_ALL=C head -c 256 -- "$path" 2>/dev/null | LC_ALL=C head -n 1 2>/dev/null | LC_ALL=C tr -dc '[:print:]' 2>/dev/null)" || first=""
683
+ path="$follow_located"
684
+ IFS= read -r -n 256 raw <"$path" 2>/dev/null || true
685
+ while [ "$k" -lt "${#raw}" ]; do
686
+ char="${raw:k:1}"
687
+ case "$char" in [[:print:]]) first="$first$char" ;; esac
688
+ k=$((k + 1))
689
+ done
678
690
  case "$first" in
679
691
  '#!'*[/[:space:]]bash* | '#!'*[/[:space:]]sh* | '#!'*[/[:space:]]zsh* \
680
692
  | '#!'*[/[:space:]]ksh* | '#!'*[/[:space:]]dash* | '#!'*[/[:space:]]ash*) ;;
@@ -704,7 +716,7 @@ follow_next_operand() {
704
716
  follow_scan() {
705
717
  local text="$1" depth="$2"
706
718
  local split_out line sep stmt t tj i j n cmd_pos eval_mode inline arg_exec
707
- local heredoc redirected pipe_cat prev_cat pending_operand
719
+ local heredoc redirected pipe_cat prev_cat pending_operand stdin_mode
708
720
  local find_mode dispatch_pending
709
721
  local -a toks=()
710
722
  split_out="$(follow_split "$text")"
@@ -891,6 +903,7 @@ follow_scan() {
891
903
  inline=0
892
904
  redirected=""
893
905
  heredoc=0
906
+ stdin_mode=0
894
907
  j=$((i + 1))
895
908
  while [ "$j" -lt "$n" ]; do
896
909
  follow_unwrap "${toks[j]}"
@@ -923,6 +936,7 @@ follow_scan() {
923
936
  ;;
924
937
  --*) j=$((j + 1)) ;;
925
938
  -*c*) inline=1; j=$((j + 1)) ;;
939
+ -s | -[a-zA-Z]*s[a-zA-Z]*) stdin_mode=1; j=$((j + 1)) ;;
926
940
  -*) j=$((j + 1)) ;;
927
941
  *) break ;;
928
942
  esac
@@ -947,6 +961,14 @@ follow_scan() {
947
961
  i="$j"
948
962
  continue
949
963
  fi
964
+ if [ "$stdin_mode" -eq 1 ]; then
965
+ # `sh -s -- -y` reads its program from stdin; tokens after `--` are
966
+ # positional arguments to that program, never a script path.
967
+ [ -z "$pipe_cat" ] || follow_target "$pipe_cat" "$depth"
968
+ cmd_pos=0
969
+ i="$n"
970
+ continue
971
+ fi
950
972
  if [ "$j" -ge "$n" ]; then
951
973
  # A dispatcher supplies the script from ANOTHER command's output, so
952
974
  # the operand exists but is unreadable here. That is the fail-closed
@@ -1809,15 +1831,14 @@ readonly SQL_TRUNCATE_EVIDENCE='\btruncate[[:space:]]+(table[[:space:]]+[[:alnum
1809
1831
  # the same command must also invoke a command-line database client. Prose in a
1810
1832
  # tracker filing or a commit message invokes none, which is exactly the
1811
1833
  # difference the text scan could not see on its own.
1812
- readonly SQL_CLIENT='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?(psql|pgcli|mysql|mysqladmin|mysqlsh|mariadb|mycli|sqlite3|sqlcmd|cqlsh|clickhouse-client|cockroach|mongosh|duckdb|usql)([[:space:]]|$)'
1813
- readonly SQL_TRUNCATE_BARE='\btruncate[[:space:]]+(only[[:space:]]+)?[[:alnum:]_."`]'
1834
+ readonly SQL_CLIENT_TRUNCATE='(^|[;&|()][[:space:]]*)([[:alnum:]_./-]*/)?(psql|pgcli|mysql|mysqladmin|mysqlsh|mariadb|mycli|sqlite3|sqlcmd|cqlsh|clickhouse-client|cockroach|mongosh|duckdb|usql)([[:space:]]+[^;&|()]*)?[[:space:]]+(-e|-c|--command|--eval)(=|[[:space:]]+)[^;&|()]*\btruncate[[:space:]]+(only[[:space:]]+)?[[:alnum:]_."`]'
1814
1835
  # Residual, accepted and stated: a bare `TRUNCATE <name>` with no terminator,
1815
1836
  # no qualification and no recognized client in the same command goes unmatched
1816
1837
  # — the same trade the `drop` arm has always made for `drop the ball`. No DROP
1817
1838
  # shape is weakened, and the guard still fails in the safe direction.
1818
1839
  if matches "$SQL_DROP" \
1819
1840
  || matches "$SQL_TRUNCATE_EVIDENCE" \
1820
- || { matches "$SQL_CLIENT" && matches "$SQL_TRUNCATE_BARE"; }; then
1841
+ || matches "$SQL_CLIENT_TRUNCATE"; then
1821
1842
  block "destructive SQL (DROP/TRUNCATE) detected"
1822
1843
  fi
1823
1844
 
@@ -1322,9 +1322,10 @@ function inputsDigest() {
1322
1322
  * @param {object|null} options.gates The gates block that was executed.
1323
1323
  * @param {string} [options.runner] The task-runner prefix.
1324
1324
  * @param {Record<string,string>|null} [options.scripts] Project scripts.
1325
+ * @param {object} options.identity Lisa artifact identity captured at run start.
1325
1326
  * @returns {object} The contract binding.
1326
1327
  */
1327
- function evidenceContract({ moment, gates, runner, scripts = null }) {
1328
+ function evidenceContract({ moment, gates, runner, scripts = null, identity }) {
1328
1329
  return {
1329
1330
  moment,
1330
1331
  runner: runner ?? null,
@@ -1337,7 +1338,7 @@ function evidenceContract({ moment, gates, runner, scripts = null }) {
1337
1338
  // produced the verdict — a fact a workflow ref cannot carry, because the
1338
1339
  // wrapper's identity and the enforcement's identity are independent when
1339
1340
  // the enforcement is a file living in the consuming repository.
1340
- ...lisaIdentity(),
1341
+ ...identity,
1341
1342
  inputs_digest: inputsDigest(),
1342
1343
  };
1343
1344
  }
@@ -1484,6 +1485,7 @@ function gateEvidence(outcome, observedAt) {
1484
1485
  * @param {string} [options.runner] The task-runner prefix.
1485
1486
  * @param {Record<string,string>|null} [options.scripts] Project scripts.
1486
1487
  * @param {string} options.observedAt When the run began, ISO-8601.
1488
+ * @param {object} [options.identity] Lisa identity captured for this run.
1487
1489
  * @returns {object} The evidence envelope.
1488
1490
  */
1489
1491
  export function evidenceDocument({
@@ -1494,6 +1496,7 @@ export function evidenceDocument({
1494
1496
  runner = undefined,
1495
1497
  scripts = null,
1496
1498
  observedAt,
1499
+ identity = lisaIdentity(),
1497
1500
  }) {
1498
1501
  const observations = (result?.results ?? []).map(outcome =>
1499
1502
  gateEvidence(outcome, observedAt)
@@ -1502,7 +1505,7 @@ export function evidenceDocument({
1502
1505
  schema: EVIDENCE_SCHEMA,
1503
1506
  verdict,
1504
1507
  subject: evidenceSubject(),
1505
- contract: evidenceContract({ gates, moment, runner, scripts }),
1508
+ contract: evidenceContract({ gates, moment, runner, scripts, identity }),
1506
1509
  producer: evidenceProducer(),
1507
1510
  observed_at: observedAt,
1508
1511
  gates: observations,
@@ -1644,7 +1647,8 @@ function main() {
1644
1647
  // defect this line exists to close: the local hook runs the version this
1645
1648
  // project PINNED, while CI runs a floating ref, so two reports about "the
1646
1649
  // gate" are routinely claims about different code.
1647
- console.log(formatIdentityLine(lisaIdentity()));
1650
+ const identity = lisaIdentity();
1651
+ console.log(formatIdentityLine(identity));
1648
1652
 
1649
1653
  /**
1650
1654
  * Record this run, and refuse to report a verdict we could not record.
@@ -1677,6 +1681,7 @@ function main() {
1677
1681
  runner: parts.runner,
1678
1682
  scripts: parts.scripts ?? null,
1679
1683
  verdict,
1684
+ identity,
1680
1685
  })
1681
1686
  );
1682
1687
  if (written) return code;
@@ -414,6 +414,9 @@ function statePath() {
414
414
  return resolve(git(["rev-parse", "--git-path", "lisa/work-item.json"]));
415
415
  }
416
416
 
417
+ /** Canonical prefix on a fully qualified local branch ref. */
418
+ const HEADS_PREFIX = "refs/heads/";
419
+
417
420
  function currentBranch() {
418
421
  return git(["branch", "--show-current"]);
419
422
  }
@@ -431,8 +434,8 @@ function rebaseBranch() {
431
434
  );
432
435
  if (!existsSync(file)) continue;
433
436
  const headName = readFileSync(file, "utf8").trim();
434
- if (headName.startsWith("refs/heads/")) {
435
- return headName.slice("refs/heads/".length);
437
+ if (headName.startsWith(HEADS_PREFIX)) {
438
+ return headName.slice(HEADS_PREFIX.length);
436
439
  }
437
440
  }
438
441
  return "";
@@ -799,7 +802,10 @@ export function deployBranchEnvironments(config) {
799
802
  : [];
800
803
  const mapped = new Map();
801
804
  for (const [environment, branch] of entries) {
802
- const name = typeof branch === "string" ? branch.trim() : "";
805
+ const trimmed = typeof branch === "string" ? branch.trim() : "";
806
+ const name = trimmed.startsWith(HEADS_PREFIX)
807
+ ? trimmed.slice(HEADS_PREFIX.length)
808
+ : trimmed;
803
809
  if (name !== "") mapped.set(name, String(environment).trim().toLowerCase());
804
810
  }
805
811
  if (mapped.size > 0) return mapped;
@@ -2434,9 +2440,6 @@ function parsePushGroups(input, remote) {
2434
2440
  return groups;
2435
2441
  }
2436
2442
 
2437
- /** The `refs/heads/` prefix, spelled once because three parsers below strip it. */
2438
- const HEADS_PREFIX = "refs/heads/";
2439
-
2440
2443
  /**
2441
2444
  * Deploy branches this push must never reach by inheritance.
2442
2445
  *
@@ -16,6 +16,7 @@
16
16
  # *** Add File: <path>
17
17
  # *** Update File: <path>
18
18
  # *** Delete File: <path>
19
+ # *** Move to: <path>
19
20
  # A single patch may carry MANY files, so callers must loop over the output.
20
21
 
21
22
  # Print, one per line, every file path the tool envelope intends to write.
@@ -41,6 +42,9 @@ lisa_extract_edit_paths() {
41
42
  "*** Add File: "* | "*** Update File: "* | "*** Delete File: "*)
42
43
  printf '%s\n' "${line#*File: }"
43
44
  ;;
45
+ "*** Move to: "*)
46
+ printf '%s\n' "${line#*** Move to: }"
47
+ ;;
44
48
  esac
45
49
  done <<<"$patch_text"
46
50
  else
@@ -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,CAo9BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAu3BjC,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,CA89BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAi4BjC,CAAC"}