Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions host-setup/agent-safety/claude/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions host-setup/agent-safety/claude/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down
Loading