diff --git a/host-setup/agent-safety/claude/install.py b/host-setup/agent-safety/claude/install.py index 1d8a678d9..514388537 100755 --- a/host-setup/agent-safety/claude/install.py +++ b/host-setup/agent-safety/claude/install.py @@ -127,6 +127,18 @@ def matcher_sees_bash(matcher): return False +def matcher_covers_every_exit_reason(matcher): + """Whether a SessionEnd matcher's shape runs on every exit reason rather than naming specific ones. + + A SessionEnd matcher filters by exit reason the way a PreToolUse matcher filters by tool: an + absent or empty matcher runs on every reason and `*` is the all-reasons spelling, so none of + the three is a defect. Unlike `matcher_sees_bash`, there is no single reason to test regex + membership against, since the sweep must fire on every exit reason rather than one particular + one, so this classifies the shape rather than evaluating a pattern. + """ + return matcher is None or matcher in ("", "*") + + def owns(entry, prefix): """Whether an allow rule names the script the prefix identifies, rather than a longer path. @@ -510,12 +522,12 @@ def event_groups(event): ) continue swept += 1 - # A SessionEnd matcher filters by exit reason, so a sweep under one runs on that reason alone. + # A matcher naming specific exit reasons runs the sweep on those reasons alone. # Counting it as registered reports a machine current while the sweep never fires on an ordinary exit. - if "matcher" in group: + if not matcher_covers_every_exit_reason(group.get("matcher")): report( f"the SessionEnd sweep is registered under a matcher " - f"({group['matcher']!r}), so it runs on that exit reason alone" + f"({group.get('matcher')!r}), so it runs on that exit reason alone" ) if ends is None: pass # likewise reported as a shape error above @@ -811,13 +823,13 @@ def reject(where, held, want): done = ["PreToolUse/Bash hook registered"] # Step 2b registers the SessionEnd sweep the same strip-then-register way as the guard above. - # The group carries no matcher, so it fires on every exit reason rather than on one. + # The chosen or newly created group covers every exit reason, so the sweep fires on all of them. ends = data.setdefault("hooks", {}).setdefault("SessionEnd", []) for g in ends: hooks_list = g.get("hooks") if isinstance(hooks_list, list): hooks_list[:] = [h for h in hooks_list if SWEEP_STEM not in str(h.get("command", ""))] - end_group = next((g for g in ends if "matcher" not in g), None) + end_group = next((g for g in ends if matcher_covers_every_exit_reason(g.get("matcher"))), None) if end_group is None: end_group = {"hooks": []} ends.append(end_group) diff --git a/host-setup/agent-safety/claude/test_install.py b/host-setup/agent-safety/claude/test_install.py index d9c73a866..9ad938ff0 100755 --- a/host-setup/agent-safety/claude/test_install.py +++ b/host-setup/agent-safety/claude/test_install.py @@ -389,6 +389,25 @@ def test_reinstalling_does_not_duplicate_the_sweep(self): ] self.assertEqual(len(entries), 1) + def test_reinstalling_reuses_a_sweep_group_covering_every_reason(self): + """`*` and `""` cover every reason the same as absent, so reinstalling must reuse that group + rather than read only the matcherless shape and leave a second, empty one behind.""" + self.install() + for matcher in ("*", ""): + with self.subTest(matcher=matcher): + data = self._settings() + data["hooks"]["SessionEnd"][0]["matcher"] = matcher + self._write(data) + self.install() + groups = self._settings()["hooks"]["SessionEnd"] + owning = [ + g + for g in groups + if any(install.SWEEP_STEM in h.get("command", "") for h in g.get("hooks", [])) + ] + self.assertEqual(len(owning), 1, groups) + self.assertEqual(owning[0].get("matcher"), matcher) + def test_a_wrong_hooks_shape_is_reported_as_itself(self): """Iterating a dict yields keys and a string yields characters, so a wrong shape read as zero registrations and sent a reader to the wrong fix.""" @@ -572,6 +591,22 @@ def test_a_sweep_under_a_matcher_reports_stale(self): self.assertEqual(r.returncode, 1, r.stdout + r.stderr) self.assertIn("under a matcher", r.stdout) + def test_a_sweep_matcher_covering_every_reason_is_not_a_defect(self): + """An absent, empty, or `*` SessionEnd matcher runs on every exit reason, same as PreToolUse.""" + self.install() + for matcher in ("*", ""): + with self.subTest(matcher=matcher): + data = self._settings() + data["hooks"]["SessionEnd"][0]["matcher"] = matcher + self._write(data) + problems = install.registration_problems(self.home) + self.assertEqual([p for p in problems if "under a matcher" in p], [], matcher) + data = self._settings() + data["hooks"]["SessionEnd"][0].pop("matcher", None) + self._write(data) + problems = install.registration_problems(self.home) + self.assertEqual([p for p in problems if "under a matcher" in p], []) + def test_an_unregistered_sweep_reports_stale_rather_than_current(self): self.install() data = self._settings()