@arbiterforge/ca-pi 0.14.9 → 0.14.12

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 (40) hide show
  1. package/README.md +7 -2
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +39 -0
  4. package/plugins/ca-pi/SKILLS.md +2 -2
  5. package/plugins/ca-pi/SPRINT.md +47 -0
  6. package/plugins/ca-pi/generated/command-catalog.json +2 -2
  7. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  8. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/release.json +6 -6
  14. package/plugins/ca-pi/hooks/_approvallib.py +58 -22
  15. package/plugins/ca-pi/hooks/_artifactauthoritylib.py +1686 -0
  16. package/plugins/ca-pi/hooks/_artifactlib.py +14 -4
  17. package/plugins/ca-pi/hooks/_artifactpromptlib.py +177 -0
  18. package/plugins/ca-pi/hooks/_githooks.py +368 -39
  19. package/plugins/ca-pi/hooks/_host.py +1 -1
  20. package/plugins/ca-pi/hooks/_overrideappendlib.py +5 -1
  21. package/plugins/ca-pi/hooks/_prerequisitelib.py +44 -22
  22. package/plugins/ca-pi/hooks/_reconciliationlib.py +472 -0
  23. package/plugins/ca-pi/hooks/_sprintapprovallib.py +302 -0
  24. package/plugins/ca-pi/hooks/artifact-authority-hook.py +72 -0
  25. package/plugins/ca-pi/hooks/artifact-authority.py +80 -0
  26. package/plugins/ca-pi/hooks/artifact-reconcile.py +57 -0
  27. package/plugins/ca-pi/hooks/doctor.py +3 -1
  28. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  29. package/plugins/ca-pi/hooks/prompt-submit.py +20 -3
  30. package/plugins/ca-pi/includes/artifacts.md +99 -12
  31. package/plugins/ca-pi/includes/sprint-authority.md +69 -0
  32. package/plugins/ca-pi/package.json +1 -1
  33. package/plugins/ca-pi/routines/INDEX.md +7 -4
  34. package/plugins/ca-pi/routines/brainstorming/SKILL.md +97 -29
  35. package/plugins/ca-pi/routines/commit-gate/SKILL.md +7 -1
  36. package/plugins/ca-pi/routines/executing-plans/SKILL.md +32 -15
  37. package/plugins/ca-pi/routines/skill-author/SKILL.md +26 -5
  38. package/plugins/ca-pi/routines/writing-plans/SKILL.md +22 -10
  39. package/plugins/ca-pi/skills/ca-commit/SKILL.md +170 -16
  40. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +116 -21
@@ -270,6 +270,146 @@ def _shell_path(path):
270
270
  return path.replace("\\", "/")
271
271
 
272
272
 
273
+ # ADR-0038: a bounded, finite translator between the three known spellings of
274
+ # an absolute path on a Windows drive letter -- Windows-native (`C:/...`),
275
+ # Git-Bash/MSYS (`/c/...`), and WSL drvfs (`/mnt/c/...`). This is the ONE
276
+ # place cross-host path-form resolution happens; every caller (the Python
277
+ # registry checks below, and the shell text `_CX_RESOLVE_SH` embedded into
278
+ # the generated shim) must implement this SAME grammar, never re-derive it.
279
+ # Scope is deliberately closed: a path matching none of the three patterns
280
+ # (e.g. a WSL-native /home/... path with no Windows-drive equivalent) is left
281
+ # as its own sole candidate -- this is not a general host-layout search.
282
+ _WIN_DRIVE = re.compile(r'^([A-Za-z]):[/\\](.*)$')
283
+ _GITBASH_DRIVE = re.compile(r'^/([A-Za-z])/(.*)$')
284
+ _WSL_DRIVE = re.compile(r'^/mnt/([A-Za-z])/(.*)$')
285
+
286
+
287
+ def _path_form_candidates(path):
288
+ """Every spelling of `path` worth testing for existence (ADR-0038): the
289
+ host-native absolute form first, then the other translated forms when
290
+ `path` matches one of the three known Windows-drive spellings. Pure -- no
291
+ filesystem access.
292
+
293
+ A native Windows-drive spelling ("C:/...") is only ever offered as a
294
+ checkable candidate when this interpreter is itself native Windows
295
+ (`os.name == "nt"`): on a genuine POSIX interpreter (real WSL, Linux) that
296
+ spelling is not absolute at all and a caller testing it with
297
+ `os.path.isfile` would have it silently reinterpreted as relative to the
298
+ current directory -- a foreign-spelled entry could then resolve to an
299
+ unrelated, attacker-plantable relative path instead of correctly falling
300
+ through to the POSIX-absolute translated forms. Git-Bash resolves the
301
+ same file via its own "/<drive>/..." candidate below regardless, so
302
+ nothing is lost by withholding the native spelling there."""
303
+ normalized = _shell_path(path)
304
+ on_windows = os.name == "nt"
305
+ m = _WSL_DRIVE.match(normalized)
306
+ if m:
307
+ drive, rest = m.group(1).lower(), m.group(2)
308
+ if on_windows:
309
+ return [f"{drive.upper()}:/{rest}", normalized, f"/{drive}/{rest}"]
310
+ return [normalized, f"/{drive}/{rest}"]
311
+ m = _GITBASH_DRIVE.match(normalized)
312
+ if m:
313
+ drive, rest = m.group(1).lower(), m.group(2)
314
+ if on_windows:
315
+ return [f"{drive.upper()}:/{rest}", normalized, f"/mnt/{drive}/{rest}"]
316
+ return [normalized, f"/mnt/{drive}/{rest}"]
317
+ m = _WIN_DRIVE.match(normalized)
318
+ if m:
319
+ drive, rest = m.group(1).lower(), m.group(2)
320
+ candidates = [f"/{drive}/{rest}", f"/mnt/{drive}/{rest}"]
321
+ if on_windows:
322
+ candidates.insert(0, normalized)
323
+ return candidates
324
+ return [normalized]
325
+
326
+
327
+ def _resolve_live(path):
328
+ """The first candidate spelling of `path` (ADR-0038) naming an existing
329
+ regular file, or None if none does. The ONE Python-side entry point for
330
+ cross-host file resolution -- callers must never re-derive candidates."""
331
+ for candidate in _path_form_candidates(path):
332
+ if os.path.isfile(candidate):
333
+ return candidate
334
+ return None
335
+
336
+
337
+ def _resolve_live_dir(path):
338
+ """Directory analogue of `_resolve_live` (ADR-0038) -- used to recognize
339
+ that a sibling host's differently-spelled drop-in-dir string names the
340
+ SAME real directory this host resolves, so a shim differing only in that
341
+ spelling is not treated as stale (B3/#684 churn)."""
342
+ for candidate in _path_form_candidates(path):
343
+ if os.path.isdir(candidate):
344
+ return candidate
345
+ return None
346
+
347
+
348
+ # The bounded, finite shell-side translator (ADR-0038), using POSIX path
349
+ # semantics like `_path_form_candidates` on POSIX -- embedded verbatim into
350
+ # every generated shim so the REAL git-hook execution path (not just Python
351
+ # diagnostics) resolves a foreign-but-translatable spelling instead of
352
+ # failing closed on it. Pure POSIX sh (`case`, `cut`, `tr`, parameter
353
+ # expansion) -- no extra process spawn beyond the cheap utilities already
354
+ # used elsewhere in this shim. Prints the resolved path and returns 0 on the
355
+ # first candidate that exists; returns 1 with nothing printed when NONE of
356
+ # the (as-is, plus up to two translated) candidates resolves -- callers must
357
+ # treat that as "does not exist," preserving ADR-0014's fail-closed contract.
358
+ _CX_RESOLVE_SH = (
359
+ "_cx_resolve() {\n"
360
+ " P=$1\n"
361
+ " K=-f\n"
362
+ ' [ "${2:-}" = dir ] && K=-d\n'
363
+ ' case "$P" in\n'
364
+ " /mnt/[A-Za-z]/*)\n"
365
+ ' [ "$K" "$P" ] && { printf \'%s\' "$P"; return 0; }\n'
366
+ ' REST=${P#/mnt/?/}\n'
367
+ ' DR=$(printf \'%s\' "$P" | cut -c6)\n'
368
+ ' DRL=$(printf \'%s\' "$DR" | tr \'A-Z\' \'a-z\')\n'
369
+ ' DRU=$(printf \'%s\' "$DR" | tr \'a-z\' \'A-Z\')\n'
370
+ ' A1="/$DRL/$REST"; A2="$DRU:/$REST"\n'
371
+ " ;;\n"
372
+ " /[A-Za-z]/*)\n"
373
+ ' [ "$K" "$P" ] && { printf \'%s\' "$P"; return 0; }\n'
374
+ ' REST=${P#/?/}\n'
375
+ ' DR=$(printf \'%s\' "$P" | cut -c2)\n'
376
+ ' DRL=$(printf \'%s\' "$DR" | tr \'A-Z\' \'a-z\')\n'
377
+ ' DRU=$(printf \'%s\' "$DR" | tr \'a-z\' \'A-Z\')\n'
378
+ ' A1="/mnt/$DRL/$REST"; A2="$DRU:/$REST"\n'
379
+ " ;;\n"
380
+ " [A-Za-z]:/*)\n"
381
+ " # Deliberately do NOT test \"$P\" as-is here (unlike the two\n"
382
+ " # branches above): on a genuine POSIX sh (real WSL, Linux), a\n"
383
+ " # drive-letter-native spelling like \"C:/...\" is not absolute at\n"
384
+ " # all -- it is silently reinterpreted as relative to CWD, which\n"
385
+ " # could select an attacker-plantable file at ./C:/... instead of\n"
386
+ " # correctly falling through to the translated candidates below.\n"
387
+ " # Git-Bash resolves this same spelling too, but via A1 (\"/<drive>/\n"
388
+ " # ...\"), which Git-Bash ALSO resolves -- so nothing is lost by\n"
389
+ " # withholding the raw as-is test for this one grammar.\n"
390
+ ' DR=$(printf \'%s\' "$P" | cut -c1 | tr \'A-Z\' \'a-z\')\n'
391
+ " # ??: (two wildcards, literal colon) never matches a drive-letter\n"
392
+ " # path like C:/... (its 3rd char is / , not the required literal\n"
393
+ " # ':'), so it left REST completely unstripped -- a confirmed\n"
394
+ " # security-review regression (fixed before merge, never released).\n"
395
+ " # ?:/ (one wildcard drive letter, literal ':', literal '/') is the\n"
396
+ " # correct 3-char match for this branch's own case pattern.\n"
397
+ ' REST=${P#?:/}\n'
398
+ ' REST=${REST#/}\n'
399
+ ' A1="/$DR/$REST"; A2="/mnt/$DR/$REST"\n'
400
+ " ;;\n"
401
+ " *)\n"
402
+ ' [ "$K" "$P" ] && { printf \'%s\' "$P"; return 0; }\n'
403
+ " return 1\n"
404
+ " ;;\n"
405
+ " esac\n"
406
+ ' [ "$K" "$A1" ] && { printf \'%s\' "$A1"; return 0; }\n'
407
+ ' [ "$K" "$A2" ] && { printf \'%s\' "$A2"; return 0; }\n'
408
+ " return 1\n"
409
+ "}\n"
410
+ )
411
+
412
+
273
413
  def _path_entry_current(dropin_dir, plugin, enforcer):
274
414
  """True iff this plugin's own drop-in entry already names `enforcer`."""
275
415
  existing = _read(_path_entry_file(dropin_dir, plugin))
@@ -389,6 +529,37 @@ _FRESHNESS_PY = (
389
529
  "except OSError:\n"
390
530
  " names = []\n"
391
531
  "legacy = re.compile(r'^[0-9]+\\.[0-9]+\\.[0-9]+$')\n"
532
+ # ADR-0038: mirrors `_path_form_candidates`/`_resolve_live` exactly (a
533
+ # foreign-spelled but resolvable entry must not be treated as absent
534
+ # here, or a stale sibling would go unmarked). A native Windows-drive
535
+ # spelling ("C:/...") is only offered as a checkable candidate when this
536
+ # interpreter itself is native Windows -- on a genuine POSIX shell/
537
+ # interpreter that spelling is not absolute and would otherwise be
538
+ # silently reinterpreted as relative to CWD.
539
+ "def _resolve(p):\n"
540
+ " p = p.replace('\\\\', '/')\n"
541
+ " m = re.match(r'^/mnt/([A-Za-z])/(.*)$', p)\n"
542
+ " if m:\n"
543
+ " dr, rest = m.group(1).lower(), m.group(2)\n"
544
+ " cands = [p, '/' + dr + '/' + rest]\n"
545
+ " if os.name == 'nt':\n"
546
+ " cands.append(dr.upper() + ':/' + rest)\n"
547
+ " return any(os.path.isfile(c) for c in cands)\n"
548
+ " m = re.match(r'^/([A-Za-z])/(.*)$', p)\n"
549
+ " if m:\n"
550
+ " dr, rest = m.group(1).lower(), m.group(2)\n"
551
+ " cands = [p, '/mnt/' + dr + '/' + rest]\n"
552
+ " if os.name == 'nt':\n"
553
+ " cands.append(dr.upper() + ':/' + rest)\n"
554
+ " return any(os.path.isfile(c) for c in cands)\n"
555
+ " m = re.match(r'^([A-Za-z]):[/\\\\](.*)$', p)\n"
556
+ " if m:\n"
557
+ " dr, rest = m.group(1).lower(), m.group(2)\n"
558
+ " cands = ['/' + dr + '/' + rest, '/mnt/' + dr + '/' + rest]\n"
559
+ " if os.name == 'nt':\n"
560
+ " cands.insert(0, p)\n"
561
+ " return any(os.path.isfile(c) for c in cands)\n"
562
+ " return os.path.isfile(p)\n"
392
563
  "def _rd(p):\n"
393
564
  " try:\n"
394
565
  " with open(p, encoding='utf-8', errors='replace') as f:\n"
@@ -403,7 +574,7 @@ _FRESHNESS_PY = (
403
574
  " if legacy.fullmatch(plugin):\n"
404
575
  " continue\n"
405
576
  " path_val = _rd(os.path.join(d, n))\n"
406
- " if not path_val or not os.path.isfile(path_val):\n"
577
+ " if not path_val or not _resolve(path_val.strip()):\n"
407
578
  " continue\n"
408
579
  " seen_file = os.path.join(d, plugin + '.seen')\n"
409
580
  # `.seen` only counts as a confirmation of what's registered RIGHT NOW when
@@ -478,7 +649,7 @@ def live_registered_plugins(dropin_dir):
478
649
  if legacy.fullmatch(plugin):
479
650
  continue
480
651
  path_val = _read(os.path.join(dropin_dir, name))
481
- if path_val and os.path.isfile(path_val.strip()):
652
+ if path_val and _resolve_live(path_val.strip()):
482
653
  live.append(plugin)
483
654
  return live
484
655
 
@@ -498,9 +669,11 @@ def _read_trusted_identity(dropin_dir):
498
669
  if len(lines) != 3 or not lines[2]:
499
670
  return None
500
671
  python_path, git_path, owner = lines
501
- if not os.path.isfile(python_path) or not os.path.isfile(git_path):
672
+ resolved_python = _resolve_live(python_path)
673
+ resolved_git = _resolve_live(git_path)
674
+ if not resolved_python or not resolved_git:
502
675
  return None
503
- return python_path, git_path, owner
676
+ return resolved_python, resolved_git, owner
504
677
 
505
678
 
506
679
  def _refresh_trusted_identity(dropin_dir, plugin):
@@ -583,17 +756,33 @@ def _shim(dropin_dir, phase):
583
756
  "#!/bin/sh\n"
584
757
  f"{SENTINEL}\n"
585
758
  f"{SHIM_NOTICE}\n"
759
+ f"{_CX_RESOLVE_SH}"
586
760
  f"D={quote(_shell_path(dropin_dir))}\n"
761
+ 'D=$(_cx_resolve "$D" dir) || {\n'
762
+ ' echo "codeArbiter: hook registry directory could not be resolved '
763
+ 'across host path forms -- failing CLOSED." >&2\n'
764
+ ' exit 1\n'
765
+ '}\n'
587
766
  f'if [ -e "$D/{_TRUSTED_IDENTITY_FILE}" ] || [ -L "$D/{_TRUSTED_IDENTITY_FILE}" ]; then\n'
588
767
  f' [ -f "$D/{_TRUSTED_IDENTITY_FILE}" ] || exit 1\n'
589
768
  f' exec 3< "$D/{_TRUSTED_IDENTITY_FILE}" || exit 1\n'
590
- ' IFS= read -r PY <&3 || exit 1\n'
591
- ' IFS= read -r G <&3 || exit 1\n'
769
+ ' IFS= read -r PY_RAW <&3 || exit 1\n'
770
+ ' IFS= read -r G_RAW <&3 || exit 1\n'
592
771
  ' IFS= read -r IDENTITY_OWNER <&3 || exit 1\n'
593
772
  " IDENTITY_EXTRA=''\n"
594
773
  ' if IFS= read -r IDENTITY_EXTRA <&3 || [ -n "$IDENTITY_EXTRA" ]; then exit 1; fi\n'
595
774
  ' exec 3<&-\n'
596
- ' [ -n "$IDENTITY_OWNER" ] && [ -f "$PY" ] && [ -f "$G" ] || exit 1\n'
775
+ ' [ -n "$IDENTITY_OWNER" ] || exit 1\n'
776
+ ' PY=$(_cx_resolve "$PY_RAW") || {\n'
777
+ ' echo "codeArbiter: trusted python executable \\"$PY_RAW\\" could not be '
778
+ 'resolved (tried cross-host path-form candidates, ADR-0038) -- failing CLOSED." >&2\n'
779
+ " exit 1\n"
780
+ " }\n"
781
+ ' G=$(_cx_resolve "$G_RAW") || {\n'
782
+ ' echo "codeArbiter: trusted git executable \\"$G_RAW\\" could not be '
783
+ 'resolved (tried cross-host path-form candidates, ADR-0038) -- failing CLOSED." >&2\n'
784
+ " exit 1\n"
785
+ " }\n"
597
786
  ' export CODEARBITER_GIT_EXECUTABLE="$G"\n'
598
787
  ' export CODEARBITER_PYTHON_EXECUTABLE="$PY"\n'
599
788
  "else\n"
@@ -619,7 +808,7 @@ def _shim(dropin_dir, phase):
619
808
  ' case "$N" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n'
620
809
  ' case " $SKIP " in *" ${N%.path} "*) continue ;; esac\n'
621
810
  ' IFS= read -r E < "$c" || continue\n'
622
- ' [ -f "$E" ] || continue\n'
811
+ ' E=$(_cx_resolve "$E") || continue\n'
623
812
  ' SEEN=1\n'
624
813
  f' {invoke}'
625
814
  ' RC=$?\n'
@@ -786,14 +975,82 @@ def _hooks_current(hd, dropin_dir):
786
975
  plugin's own enforcer path. So a plugin-version bump that only changes
787
976
  `_enforcer_path()` does NOT make this return False; install() refreshes
788
977
  the plugin's OWN drop-in `.path` entry unconditionally every call,
789
- independent of whether this check short-circuits the shim-file rewrite."""
978
+ independent of whether this check short-circuits the shim-file rewrite.
979
+
980
+ B3/#684 (ADR-0038): the embedded `D=` line is host-LOCAL by necessity —
981
+ each host's shell needs a spelling IT can actually glob the drop-in dir
982
+ with, so two hosts sharing one repo legitimately compute two different
983
+ (but equally correct) `D=` strings for the very same directory. A raw
984
+ whole-body string compare therefore always disagreed across alternating
985
+ SessionStarts, reinstalling every time. When the bodies differ ONLY in
986
+ that one line, a resolved-directory comparison (`_same_dropin_dir`)
987
+ decides instead — a shim a sibling host wrote for the SAME directory is
988
+ still current; one that names a genuinely different directory is not."""
790
989
  for phase in PHASES:
791
990
  existing = _read(os.path.join(hd, phase))
792
- if existing is None or existing != _shim(dropin_dir, phase):
991
+ if existing is None:
992
+ return False
993
+ desired = _shim(dropin_dir, phase)
994
+ if not _shim_matches(existing, desired, dropin_dir):
793
995
  return False
794
996
  return True
795
997
 
796
998
 
999
+ _DROPIN_LINE_PREFIX = "D="
1000
+
1001
+
1002
+ def _shim_body_without_dropin_line(text):
1003
+ """`text` with its `D=...` line replaced by a fixed placeholder, so two
1004
+ shim bodies differing ONLY in that line's host-local spelling compare
1005
+ equal (B3/#684)."""
1006
+ out = []
1007
+ for line in text.splitlines(keepends=True):
1008
+ if line.startswith(_DROPIN_LINE_PREFIX):
1009
+ out.append(f"{_DROPIN_LINE_PREFIX}<dropin>\n")
1010
+ else:
1011
+ out.append(line)
1012
+ return "".join(out)
1013
+
1014
+
1015
+ def _extract_dropin_line_value(text):
1016
+ """The shell-single-quoted value a shim's `D=` line embeds, or None."""
1017
+ for line in text.splitlines():
1018
+ if not line.startswith(_DROPIN_LINE_PREFIX):
1019
+ continue
1020
+ inner = line[len(_DROPIN_LINE_PREFIX):]
1021
+ if inner.startswith("'") and inner.endswith("'"):
1022
+ return inner[1:-1].replace("'\"'\"'", "'")
1023
+ return None
1024
+ return None
1025
+
1026
+
1027
+ def _same_dropin_dir(existing_value, dropin_dir):
1028
+ """True iff `existing_value` (a `D=` string a shim already embeds,
1029
+ possibly written by a SIBLING host in its own spelling) names the SAME
1030
+ directory as `dropin_dir` (this host's own resolution) — tried as-is and
1031
+ via the bounded cross-host path-form candidates (ADR-0038)."""
1032
+ if existing_value is None:
1033
+ return False
1034
+ target = _shell_path(dropin_dir)
1035
+ if existing_value == target:
1036
+ return True
1037
+ if not os.path.isdir(dropin_dir):
1038
+ return False
1039
+ resolved = _resolve_live_dir(existing_value)
1040
+ if resolved is None:
1041
+ return False
1042
+ return os.path.realpath(resolved) == os.path.realpath(dropin_dir)
1043
+
1044
+
1045
+ def _shim_matches(existing, desired, dropin_dir):
1046
+ """Accept only an identical shim or a sibling-host D= for this same dir."""
1047
+ return existing == desired or (
1048
+ _shim_body_without_dropin_line(existing)
1049
+ == _shim_body_without_dropin_line(desired)
1050
+ and _same_dropin_dir(_extract_dropin_line_value(existing), dropin_dir)
1051
+ )
1052
+
1053
+
797
1054
  def _default_hooks_dir(root):
798
1055
  return os.path.join(root, ".git", "hooks")
799
1056
 
@@ -857,36 +1114,108 @@ def install(root):
857
1114
  _warn(f"could not create hooks dir {hd}; skipping git-hook install")
858
1115
  return []
859
1116
  actions = []
860
- for phase in PHASES:
861
- dest = os.path.join(hd, phase)
862
- desired = _shim(dropin_dir, phase)
863
- if os.path.exists(dest):
864
- existing = _read(dest)
865
- lines = existing.splitlines() if existing is not None else []
866
- managed = len(lines) >= 2 and lines[0] == "#!/bin/sh" and (
867
- lines[1] == SENTINEL or lines[1].startswith(f"{SENTINEL} — ")
868
- )
869
- if existing is not None and not managed:
870
- _warn(f"an existing {phase} hook is not codeArbiter-managed — leaving it "
871
- f"untouched. For git-level enforcement, call "
872
- f"'{os.path.basename(enforcer)} {phase}' from it (see includes docs).")
873
- actions.append(f"{phase}: foreign hook preserved (not installed)")
874
- continue
875
- if existing == desired:
876
- continue # already current — no churn
1117
+ # B1/#686: pre-commit and pre-push are written as a PAIR. Two concurrent
1118
+ # install() calls (typically two different hosts' SessionStarts racing
1119
+ # each other) can otherwise interleave their writes, and even a single
1120
+ # process's own mid-loop failure (disk full, AV scan, a locked drvfs
1121
+ # file) can leave one phase upgraded and its sibling not — either way a
1122
+ # permanently split, mutually-inconsistent pair until a later lone
1123
+ # session happens to reinstall both together. A cross-process lock
1124
+ # serializes concurrent installers; a plan-then-write-then-rollback
1125
+ # sequence ensures a failure partway through this call restores the
1126
+ # PRIOR consistent pair rather than leaving a new/old split.
1127
+ #
1128
+ # Documented residual (accepted, security-review-noted): the rollback
1129
+ # write below is itself best-effort. If the SAME failure mode that broke
1130
+ # the original write (disk full, an AV lock) also blocks the rollback
1131
+ # write for an already-written phase, the pair can still end up split.
1132
+ # This requires two independent write failures in one call, versus zero
1133
+ # rollback at all before this fix (any single failure could split the
1134
+ # pair) — narrower, not eliminated.
1135
+ #
1136
+ # The lock is acquired BEFORE reading the existing hooks or computing the
1137
+ # plan, not just around the writes: reading "existing" outside the lock
1138
+ # would let a concurrent installer's write land between this read and
1139
+ # this call's own write, so the "already current" skip and the rollback
1140
+ # `prior` snapshot could both be stale relative to what is actually on
1141
+ # disk the moment this call writes — reintroducing exactly the split-pair
1142
+ # and churn failures this lock exists to prevent (security review).
1143
+ lock_handle = _hooklib.acquire_lock(os.path.join(dropin_dir, "install"))
1144
+ if lock_handle is None:
1145
+ _warn("could not acquire the shared install lock (B1/#686); leaving the "
1146
+ "existing pre-commit/pre-push pair untouched this session — a later "
1147
+ "session will retry rather than risk writing an unlocked, possibly "
1148
+ "split pair")
1149
+ else:
877
1150
  try:
878
- # reliability-010: atomic sibling-temp + os.replace (mirrors
879
- # write_provenance/save_state). A crash mid-write with a plain
880
- # open('w') could leave a sentinel-less partial shim that the
881
- # foreign-hook guard above then preserves forever; os.replace
882
- # guarantees `dest` is either the complete new shim or the prior
883
- # (sentinel-bearing, or absent) file — never a torn write.
884
- _hooklib.write_text_atomic(dest, desired, newline="\n")
885
- st = os.stat(dest)
886
- os.chmod(dest, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
887
- actions.append(f"{phase}: installed")
888
- except Exception as e: # noqa: BLE001
889
- _warn(f"could not write {dest}: {e}")
1151
+ plan = []
1152
+ for phase in PHASES:
1153
+ dest = os.path.join(hd, phase)
1154
+ desired = _shim(dropin_dir, phase)
1155
+ existing = _read(dest) if os.path.exists(dest) else None
1156
+ if existing is not None:
1157
+ lines = existing.splitlines()
1158
+ managed = len(lines) >= 2 and lines[0] == "#!/bin/sh" and (
1159
+ lines[1] == SENTINEL or lines[1].startswith(f"{SENTINEL} — ")
1160
+ )
1161
+ if not managed:
1162
+ _warn(f"an existing {phase} hook is not codeArbiter-managed — leaving "
1163
+ f"it untouched. For git-level enforcement, call "
1164
+ f"'{os.path.basename(enforcer)} {phase}' from it (see includes "
1165
+ f"docs).")
1166
+ actions.append(f"{phase}: foreign hook preserved (not installed)")
1167
+ continue
1168
+ if _shim_matches(existing, desired, dropin_dir):
1169
+ continue # already current — no churn
1170
+ plan.append((phase, dest, desired, existing))
1171
+
1172
+ if plan:
1173
+ written = []
1174
+ failure = None
1175
+ for phase, dest, desired, prior in plan:
1176
+ # Capture the destination's CURRENT mode before replacing it —
1177
+ # rollback restores this exact mode, not just "executable",
1178
+ # so a foreign hook's own permission bits survive a rollback
1179
+ # (security review).
1180
+ try:
1181
+ prior_mode = (
1182
+ stat.S_IMODE(os.stat(dest).st_mode) if prior is not None else None
1183
+ )
1184
+ # reliability-010: atomic sibling-temp + os.replace (mirrors
1185
+ # write_provenance/save_state) — os.replace guarantees `dest`
1186
+ # is either the complete new shim or the prior file, never a
1187
+ # torn write.
1188
+ _hooklib.write_text_atomic(dest, desired, newline="\n")
1189
+ # Recorded as soon as the content write lands — a chmod
1190
+ # failure just below must still roll this phase back
1191
+ # (security review): before this fix a chmod-only failure
1192
+ # left the new content in place, untracked for rollback.
1193
+ written.append((phase, dest, prior, prior_mode))
1194
+ st = os.stat(dest)
1195
+ os.chmod(dest, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
1196
+ except Exception as e: # noqa: BLE001
1197
+ failure = (dest, e)
1198
+ break
1199
+ if failure is None:
1200
+ for phase, _dest, _prior, _prior_mode in written:
1201
+ actions.append(f"{phase}: installed")
1202
+ else:
1203
+ for _phase, dest, prior, prior_mode in written:
1204
+ try:
1205
+ if prior is None:
1206
+ os.remove(dest)
1207
+ else:
1208
+ _hooklib.write_text_atomic(dest, prior, newline="\n")
1209
+ if prior_mode is not None:
1210
+ os.chmod(dest, prior_mode)
1211
+ except Exception: # noqa: BLE001 — rollback is best-effort
1212
+ pass
1213
+ fail_dest, fail_exc = failure
1214
+ _warn(f"could not write {fail_dest}: {fail_exc} — rolled back "
1215
+ f"{len(written)} already-written phase(s) to avoid a split "
1216
+ f"pre-commit/pre-push pair (B1/#686)")
1217
+ finally:
1218
+ _hooklib.release_lock(lock_handle)
890
1219
  # Cache the resolved location so the NEXT call can skip the git-config/
891
1220
  # rev-parse re-probe entirely (performance-002) — best-effort, never fatal.
892
1221
  _write_hooks_dir_cache(root, hd)
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
11
11
  class PiHost(hostapi.Host):
12
12
  name = "pi"
13
13
  adapter_name = "@arbiterforge/ca-pi"
14
- adapter_version = "0.14.9"
14
+ adapter_version = "0.14.12"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -78,7 +78,11 @@ def _validate_file_type(path):
78
78
 
79
79
  def _validate_existing(path, raw):
80
80
  _validate_file_type(path)
81
- if raw.startswith(b"\xef\xbb\xbf") or b"\x00" in raw or b"\r" in raw:
81
+ # Historical audit bytes are immutable. A stray CR can exist in an older
82
+ # row without making a new O_APPEND write unsafe, so preserve it verbatim
83
+ # and require only that newly written rows use LF. Rewriting the file to
84
+ # normalize old bytes would violate the append-only contract.
85
+ if raw.startswith(b"\xef\xbb\xbf") or b"\x00" in raw:
82
86
  raise OverrideAppendError("overrides.log is not canonical UTF-8 LF text")
83
87
  try:
84
88
  raw.decode("utf-8", errors="strict")