116116from _durabilitylib import is_ephemeral_path
117117from _gitexec import git_executable , trusted_git_executable , trusted_python_executable
118118
119- SENTINEL = "# codeArbiter-managed git hook (#161) — refreshed each session; edits are overwritten."
119+ SENTINEL = (
120+ "# codeArbiter-managed git hook (#161) — this SHIM is refreshed by any live "
121+ "host's session (it is host-neutral, ADR-0014); the plugin-specific enforcer "
122+ "entries it dispatches to (.git/codearbiter-hooksd/*.path) each self-heal "
123+ "only on THAT plugin's own next session (#556) — edits here are overwritten."
124+ )
120125PHASES = ("pre-commit" , "pre-push" )
121126# The hooks_dir() resolution cache lives INSIDE .git/ itself (never under
122127# .codearbiter/): a linked worktree's `.git` is a FILE (not a directory)
@@ -315,6 +320,141 @@ def _write_path_entry(dropin_dir, plugin, enforcer):
315320 return False
316321
317322
323+ def _seen_marker_file (dropin_dir , plugin ):
324+ return os .path .join (dropin_dir , f"{ plugin } .seen" )
325+
326+
327+ def _touch_seen_marker (dropin_dir , plugin , enforcer ):
328+ """Best-effort (never fatal) freshness heartbeat for `plugin` (#556).
329+
330+ Records the SAME enforcer value `_write_path_entry` just confirmed in
331+ `<plugin>.path` — content-addressed on purpose. The freshness guard below
332+ only trusts this heartbeat's mtime when its recorded content still
333+ matches `<plugin>.path`'s CURRENT content; a raw mtime-only heartbeat
334+ (compared only against `.path`'s own mtime) is a sub-millisecond race on
335+ some filesystems whenever a `.path` entry is rewritten by something other
336+ than `install()` shortly after a real install (exactly what several
337+ existing drop-in tests simulate to probe unrelated behavior) — content
338+ equality has no such timing dependency.
339+
340+ Unlike `<plugin>.path` (which `_write_path_entry` deliberately leaves
341+ untouched when its content hasn't changed, to avoid churn), THIS file is
342+ rewritten every live session regardless of whether the `.path` entry
343+ itself changed — it is the signal a sibling plugin's cache has gone
344+ stale. A plugin whose host never runs a session again simply stops
345+ updating its `.seen` file, which is exactly the staleness #556 needs
346+ surfaced.
347+
348+ Callers must skip this for an ephemeral enforcer (mirroring
349+ `_write_path_entry`'s own refusal) — an ephemeral session confirming
350+ freshness would be exactly the wrong direction: it would make a
351+ sibling's genuinely durable, still-correct entry look stale by
352+ comparison."""
353+ try :
354+ os .makedirs (dropin_dir , exist_ok = True )
355+ _hooklib .write_text_atomic (
356+ _seen_marker_file (dropin_dir , plugin ), _shell_path (enforcer ) + "\n " , newline = "\n " )
357+ except Exception : # noqa: BLE001
358+ pass
359+
360+
361+ # The freshness probe embedded VERBATIM into every generated shim (via a
362+ # stdin heredoc, see `_shim()`) AND run identically by `stale_registered_plugins`
363+ # below for `/ca:doctor` (#556, AC-3). Deliberately a single string constant
364+ # run in BOTH places rather than two hand-kept implementations: the shim
365+ # cannot import any plugin's `_githooks.py` to get this logic (whichever
366+ # plugin's copy it picked could itself be the stale one this guard exists to
367+ # distrust — the exact #556 failure, one level up), so it must be entirely
368+ # self-contained text that the CURRENTLY installing (never stale — the shim
369+ # file itself is regenerated by whatever live host runs `install()`, see
370+ # SENTINEL) session bakes in. `/ca:doctor` runs the SAME text as a real
371+ # subprocess instead of a parallel port, so the two can never drift.
372+ #
373+ # Algorithm: a plugin's registered entry is "stale" — printed to stdout, one
374+ # per line — iff (a) at least one OTHER registered entry in the same
375+ # drop-in dir has recorded a `.seen` heartbeat, AND (b) this plugin's own
376+ # heartbeat is either absent or strictly older than the freshest one seen.
377+ # When NOBODY has ever recorded a heartbeat (a repo that predates #556, or
378+ # every registered plugin genuinely dormant), nothing is printed — the
379+ # caller then treats every entry as before this fix (the original, safe
380+ # fail-closed "run everything" default), never silently disabling
381+ # enforcement outright.
382+ _FRESHNESS_PY = (
383+ "import os, re, sys\n "
384+ "d = sys.argv[1] if len(sys.argv) > 1 else ''\n "
385+ "try:\n "
386+ " names = os.listdir(d)\n "
387+ "except OSError:\n "
388+ " names = []\n "
389+ "legacy = re.compile(r'^[0-9]+\\ .[0-9]+\\ .[0-9]+$')\n "
390+ "def _rd(p):\n "
391+ " try:\n "
392+ " with open(p, encoding='utf-8', errors='replace') as f:\n "
393+ " return f.read().strip()\n "
394+ " except OSError:\n "
395+ " return None\n "
396+ "entries = []\n "
397+ "for n in sorted(names):\n "
398+ " if not n.endswith('.path'):\n "
399+ " continue\n "
400+ " plugin = n[:-len('.path')]\n "
401+ " if legacy.fullmatch(plugin):\n "
402+ " continue\n "
403+ " path_val = _rd(os.path.join(d, n))\n "
404+ " if path_val is None:\n "
405+ " continue\n "
406+ " seen_file = os.path.join(d, plugin + '.seen')\n "
407+ # `.seen` only counts as a confirmation of what's registered RIGHT NOW when
408+ # its recorded value still matches `.path`'s CURRENT content -- content
409+ # equality, never a raw mtime-ordering guess. A `.path` entry rewritten by
410+ # something other than install() (a version bump landing between two
411+ # sessions, or -- in this suite's own drop-in fixtures -- a direct
412+ # overwrite that never re-confirms) leaves a `.seen` file whose value no
413+ # longer matches, which must NOT count as evidence for the new content: a
414+ # sub-millisecond mtime race between two nearly-simultaneous writes is not
415+ # a reliable ordering signal on every filesystem, but string equality has
416+ # no timing dependency at all.
417+ " confirmed = None\n "
418+ " if _rd(seen_file) == path_val:\n "
419+ " try:\n "
420+ " confirmed = os.stat(seen_file).st_mtime\n "
421+ " except OSError:\n "
422+ " confirmed = None\n "
423+ " entries.append((plugin, confirmed))\n "
424+ "known = [m for _, m in entries if m is not None]\n "
425+ "if known:\n "
426+ " mx = max(known)\n "
427+ " for plugin, m in entries:\n "
428+ " if m is None or m < mx:\n "
429+ " print(plugin)\n "
430+ )
431+
432+
433+ def stale_registered_plugins (dropin_dir ):
434+ """Plugin names whose drop-in `.path` entry the generated shim's
435+ freshness guard (#556) will SKIP at the next commit/push, because a
436+ fresher registered sibling exists. Runs `_FRESHNESS_PY` as a real
437+ subprocess of THIS interpreter — never a hand-kept parallel
438+ implementation — so `/ca:doctor` (AC-3) and the shim can never disagree.
439+
440+ Returns [] when `dropin_dir` doesn't exist, nothing is registered, or
441+ the probe fails for any reason — a diagnostic must never be able to
442+ raise into its caller."""
443+ if not dropin_dir or not os .path .isdir (dropin_dir ):
444+ return []
445+ try :
446+ r = subprocess .run (
447+ [sys .executable , "-" , dropin_dir ], input = _FRESHNESS_PY ,
448+ capture_output = True , text = True , encoding = "utf-8" , errors = "replace" ,
449+ timeout = 5 ,
450+ )
451+ except Exception : # noqa: BLE001
452+ return []
453+ if r .returncode != 0 :
454+ return []
455+ return [ln for ln in r .stdout .splitlines () if ln .strip ()]
456+
457+
318458_TRUSTED_IDENTITY_FILE = "trusted-executables.identity"
319459
320460
@@ -368,10 +508,10 @@ def _shim(dropin_dir, phase):
368508 # Single-interpreter selection preserves stdin (pre-push) and the BLOCK
369509 # exit code. The shim is HOST-NEUTRAL (ADR-0014): it embeds no plugin-
370510 # specific enforcer path, only the shared drop-in directory. It iterates
371- # every "*.path" entry there and runs EVERY enforcer that resolves. Any
372- # non-zero verdict blocks, so an older live sibling can never mask a newer
373- # guard . A dead entry from an uninstalled plugin is skipped. An unmatched
374- # glob (dir absent or
511+ # every "*.path" entry there and runs every enforcer that resolves AND is
512+ # not recognized as stale (#556, below) — any non-zero verdict from one of
513+ # those blocks . A dead entry from an uninstalled plugin is skipped. An
514+ # unmatched glob (dir absent or
375515 # empty) leaves `c` as the literal, un-expanded "$D/*.path" string in
376516 # POSIX `sh` — `[ -f "$c" ]` on that literal correctly fails too, so the
377517 # loop falls straight through to the same fail-closed tail with no special
@@ -381,6 +521,23 @@ def _shim(dropin_dir, phase):
381521 # provides trusted executable identities, install() persists them beside
382522 # the registry. Identity-less hosts preserve that set, so a later Claude or
383523 # Codex session cannot downgrade Pi's absolute executable boundary.
524+ #
525+ # #556 (AC-1): "any non-zero verdict blocks" used to mean an entry that
526+ # nobody has refreshed in months — a host cache that predates a fix THIS
527+ # checkout already carries, e.g. the #279 sensitive-scan exemption — could
528+ # resurrect an already-closed false positive with no in-session exit but
529+ # an override. Before running the loop, `$SKIP` is populated (via
530+ # `_FRESHNESS_PY`, run once here from a heredoc so this is never delegated
531+ # to any specific plugin's own — possibly stale — `_githooks.py`) with the
532+ # plugin names whose `.seen` heartbeat (written every live session,
533+ # unconditionally, by `install()`) is missing or older than a sibling's.
534+ # Those entries are skipped WITHOUT running their python at all, deferring
535+ # to whichever registered sibling a live session confirmed more recently.
536+ # When NO entry anywhere has ever recorded a heartbeat (a repo that
537+ # predates this fix, or a wholly dormant install), `$SKIP` is empty and
538+ # every entry runs exactly as before — this can only ever narrow which
539+ # entries run, never widen it, so a genuine `SEEN=0` fail-closed case is
540+ # unaffected.
384541 def quote (value ):
385542 return "'" + value .replace ("'" , "'\" '\" '" ) + "'"
386543
@@ -414,10 +571,24 @@ def quote(value):
414571 ' if python3 -c "" 2>/dev/null; then PY=python3; else PY=python; fi\n '
415572 "fi\n "
416573 f"{ capture } "
574+ # #556: computed once per hook firing, from a literal heredoc (never
575+ # an `import` of any plugin's own `_githooks.py`) so this stays
576+ # correct even when every REGISTERED enforcer is stale — only the
577+ # currently-installing session's freshly generated shim needs to be
578+ # current for this to work. A crash/empty result here just leaves
579+ # $SKIP empty (see `[ "$RC" -eq 0 ] || exit "$RC"` below — command
580+ # substitution failure doesn't abort `sh`), the original run-everything
581+ # behavior.
582+ "SKIP=$(\" $PY\" - \" $D\" <<'CODEARBITER_556_FRESHNESS'\n "
583+ f"{ _FRESHNESS_PY } "
584+ "CODEARBITER_556_FRESHNESS\n "
585+ ")\n "
417586 "SEEN=0\n "
418587 'for c in "$D"/*.path; do\n '
419588 ' [ -f "$c" ] || continue\n '
420- ' case "${c##*/}" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n '
589+ ' N=${c##*/}\n '
590+ ' case "$N" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n '
591+ ' case " $SKIP " in *" ${N%.path} "*) continue ;; esac\n '
421592 ' IFS= read -r E < "$c" || continue\n '
422593 ' [ -f "$E" ] || continue\n '
423594 ' SEEN=1\n '
@@ -645,7 +816,8 @@ def install(root):
645816 if (cached_norm == default_hd
646817 and _confirmed_no_local_hooks_path (root )
647818 and _hooks_current (cached_hd , dropin_dir )):
648- _write_path_entry (dropin_dir , plugin , enforcer )
819+ if _write_path_entry (dropin_dir , plugin , enforcer ):
820+ _touch_seen_marker (dropin_dir , plugin , enforcer ) # #556 freshness heartbeat
649821 return []
650822 hd = hooks_dir (root )
651823 if not hd :
@@ -686,9 +858,12 @@ def install(root):
686858 # rev-parse re-probe entirely (performance-002) — best-effort, never fatal.
687859 _write_hooks_dir_cache (root , hd )
688860 # ADR-0014: refresh THIS plugin's own drop-in entry every call, whether or
689- # not the shim files above needed a rewrite.
861+ # not the shim files above needed a rewrite. #556: the `.seen` heartbeat
862+ # is touched on every successful confirmation too (never skipped for
863+ # "no churn" the way the `.path` entry itself is) — it is the freshness
864+ # guard's only signal that a LIVE session confirmed this entry today.
690865 if _write_path_entry (dropin_dir , plugin , enforcer ):
691- pass
866+ _touch_seen_marker ( dropin_dir , plugin , enforcer )
692867 return actions
693868
694869
@@ -714,6 +889,18 @@ def uninstall(root):
714889 actions .append (f"{ plugin } .path: removed" )
715890 except Exception as e : # noqa: BLE001
716891 _warn (f"could not remove { entry } : { e } " )
892+ # #556: drop this plugin's OWN freshness heartbeat alongside its `.path`
893+ # entry — a genuinely uninstalled plugin must not keep looking "live" to
894+ # the freshness guard above (it would otherwise sit there, forever
895+ # confirmed-fresh at its last mtime, potentially outranking a sibling
896+ # that IS still being maintained).
897+ seen = _seen_marker_file (dropin_dir , plugin )
898+ if os .path .isfile (seen ):
899+ try :
900+ os .remove (seen )
901+ actions .append (f"{ plugin } .seen: removed" )
902+ except Exception as e : # noqa: BLE001
903+ _warn (f"could not remove { seen } : { e } " )
717904 identity = _read_trusted_identity (dropin_dir )
718905 if identity is not None and identity [2 ] == plugin :
719906 path = _identity_file (dropin_dir )
0 commit comments