@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.
- package/README.md +7 -2
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +39 -0
- package/plugins/ca-pi/SKILLS.md +2 -2
- package/plugins/ca-pi/SPRINT.md +47 -0
- package/plugins/ca-pi/generated/command-catalog.json +2 -2
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +6 -6
- package/plugins/ca-pi/hooks/_approvallib.py +58 -22
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +1686 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +14 -4
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +177 -0
- package/plugins/ca-pi/hooks/_githooks.py +368 -39
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_overrideappendlib.py +5 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +44 -22
- package/plugins/ca-pi/hooks/_reconciliationlib.py +472 -0
- package/plugins/ca-pi/hooks/_sprintapprovallib.py +302 -0
- package/plugins/ca-pi/hooks/artifact-authority-hook.py +72 -0
- package/plugins/ca-pi/hooks/artifact-authority.py +80 -0
- package/plugins/ca-pi/hooks/artifact-reconcile.py +57 -0
- package/plugins/ca-pi/hooks/doctor.py +3 -1
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +20 -3
- package/plugins/ca-pi/includes/artifacts.md +99 -12
- package/plugins/ca-pi/includes/sprint-authority.md +69 -0
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +7 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +97 -29
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +7 -1
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +32 -15
- package/plugins/ca-pi/routines/skill-author/SKILL.md +26 -5
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +22 -10
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +170 -16
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
591
|
-
' IFS= read -r
|
|
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" ]
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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.
|
|
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
|
-
|
|
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")
|