From 42871ec433732b23bd3322f6e853ba82af68a088 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 13:15:29 -0700 Subject: [PATCH 1/5] Reject a Repeated Tier Name and Name the Third Hook in the Installer The tier validator rejects a tiers list that repeats a name, which the set comparison against each tool's models let through. The installer's module docstring names both PreToolUse hooks, the model-tier guard's mapping, and the three hooks it registers. Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/install.py | 11 ++++++----- spec/validate.py | 2 ++ tests/test_spec_validate.py | 5 +++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/host-setup/agent-safety/claude/install.py b/host-setup/agent-safety/claude/install.py index d5b24d5b4..6749ed392 100755 --- a/host-setup/agent-safety/claude/install.py +++ b/host-setup/agent-safety/claude/install.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 """Install the agent host-safety kit for the current user account. Cross-platform, idempotent. -Deploys the PreToolUse hook, the SessionEnd stray-process sweep, and the tool-containment shell prefix, -registers the two hooks in the user settings.json and, on a host with a systemd user manager, the prefix -in its `env`, merges the permission rules this kit owns into the same file, renders the user -CLAUDE.md whole from the kit's blocks and the host-local instruction file, and self-tests each hook -before registering it. +Deploys the two PreToolUse hooks, the write guard and the model-tier guard with its tier mapping, +the SessionEnd stray-process sweep, and the tool-containment shell prefix. It registers the three +hooks in the user settings.json. On a host with a systemd user manager it also sets the prefix in +that file's `env`. It merges the permission rules this kit owns into the same file. It renders the +user CLAUDE.md whole from the kit's blocks and the host-local instruction file. Each hook is +self-tested before it is registered. The bash and PowerShell wrappers both call this, so every OS runs one tested code path. Every run records a stamp at ~/.claude/agent-safety-stamp.json naming the machine, what was diff --git a/spec/validate.py b/spec/validate.py index 8e4f5c7ba..c368e0dca 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -709,6 +709,8 @@ def model_tier_errors(root): if not is_str_list(tiers) or not isinstance(tools, dict): return ["spec/model-tiers.json: tiers must be a list of strings and tools an object"] errors = [] + if len(set(tiers)) != len(tiers): + errors.append("spec/model-tiers.json: tiers must not repeat a name") if reserved not in tiers: errors.append(f"spec/model-tiers.json: maintainerOnly {reserved!r} is not a declared tier") # The default is what an orchestrator dispatches at without being told otherwise, so it can never be the tier only the maintainer selects. diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index 83566dcee..5a10c594a 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -407,6 +407,11 @@ def test_reports_a_maintainer_only_value_that_is_not_a_string(self) -> None: self.assertTrue(any("maintainerOnly" in e for e in self.errors())) + def test_rejects_a_repeated_tier_name(self) -> None: + self.config["tiers"] = ["highest", "frontier", "standard", "bulk", "bulk"] + + self.assertTrue(any("must not repeat a name" in e for e in self.errors())) + def test_rejects_a_tools_list_instead_of_an_object(self) -> None: self.config["tools"] = [] From 9dc0342a2beb21f332efbeb648b100dca8ef24e8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 13:17:30 -0700 Subject: [PATCH 2/5] Name the Write Guard in the Installer's Report Rather Than the PreToolUse Hook Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host-setup/agent-safety/claude/install.py b/host-setup/agent-safety/claude/install.py index 6749ed392..ae4d7acdf 100755 --- a/host-setup/agent-safety/claude/install.py +++ b/host-setup/agent-safety/claude/install.py @@ -1096,7 +1096,7 @@ def judge_sweep(group, hook): GUARD_NAME, ("Bash",), "guard", - "the PreToolUse hook", + "the write guard", "that group never fires", "the guard never runs", ) From b2bcafc3928ac1cee2af85990ec0faed0ac88247 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 13:19:04 -0700 Subject: [PATCH 3/5] Say Hooks Where the Installer Leaves Several Unregistered and Label the Write Guard Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/install.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/host-setup/agent-safety/claude/install.py b/host-setup/agent-safety/claude/install.py index ae4d7acdf..c72efc6c3 100755 --- a/host-setup/agent-safety/claude/install.py +++ b/host-setup/agent-safety/claude/install.py @@ -960,11 +960,11 @@ def registration_problems(claude_home): try: raw = read_regular_file(claude_home / "settings.json") except FileNotFoundError: - return ["settings.json is missing, so the hook is not registered"] + return ["settings.json is missing, so the hooks are not registered"] except OSError as e: return [f"settings.json cannot be read ({e})"] if raw is None: - return ["settings.json is not a regular file, so the hook is not registered"] + return ["settings.json is not a regular file, so the hooks are not registered"] try: data = json.loads(raw.decode("utf-8") or "{}") # ValueError rather than JSONDecodeError, since it also covers UnicodeDecodeError. @@ -1095,7 +1095,7 @@ def judge_sweep(group, hook): groups, GUARD_NAME, ("Bash",), - "guard", + "write guard", "the write guard", "that group never fires", "the guard never runs", @@ -1528,7 +1528,7 @@ def main(): if not isinstance(data, dict): sys.stderr.write( f"{settings} is valid JSON but holds {type(data).__name__} at its root where an object " - "is required. Fix or remove it, then re-run. This file is unchanged, so the hook is " + "is required. Fix or remove it, then re-run. This file is unchanged, so the hooks are " "deployed but not registered.\n" ) return 1 @@ -1540,7 +1540,7 @@ def main(): def reject(where, held, want): sys.stderr.write( f"{settings} has `{where}` as {type(held).__name__} where {want.__name__} is required. " - "Fix or remove that key, then re-run. This file is unchanged, so the hook is deployed " + "Fix or remove that key, then re-run. This file is unchanged, so the hooks are deployed " "but not registered.\n" ) From bc9a8cb6edcdd6281bb2e43735e1c57af9b00dcc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 13:32:42 -0700 Subject: [PATCH 4/5] Drop the Hook Counts From the Installer Docstring Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/install.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/host-setup/agent-safety/claude/install.py b/host-setup/agent-safety/claude/install.py index c72efc6c3..83e3ba452 100755 --- a/host-setup/agent-safety/claude/install.py +++ b/host-setup/agent-safety/claude/install.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """Install the agent host-safety kit for the current user account. Cross-platform, idempotent. -Deploys the two PreToolUse hooks, the write guard and the model-tier guard with its tier mapping, -the SessionEnd stray-process sweep, and the tool-containment shell prefix. It registers the three -hooks in the user settings.json. On a host with a systemd user manager it also sets the prefix in -that file's `env`. It merges the permission rules this kit owns into the same file. It renders the -user CLAUDE.md whole from the kit's blocks and the host-local instruction file. Each hook is -self-tested before it is registered. +Deploys the PreToolUse hooks, the write guard and the model-tier guard with its tier mapping, the +SessionEnd stray-process sweep, and the tool-containment shell prefix. It registers the hooks in the +user settings.json. On a host with a systemd user manager it also sets the prefix in that file's +`env`. It merges the permission rules this kit owns into the same file. It renders the user +CLAUDE.md whole from the kit's blocks and the host-local instruction file. Each hook is self-tested +before it is registered. The bash and PowerShell wrappers both call this, so every OS runs one tested code path. Every run records a stamp at ~/.claude/agent-safety-stamp.json naming the machine, what was From 1b59b7cf15ad744dec9a27a59402a3b7ccc704c6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 13:33:21 -0700 Subject: [PATCH 5/5] Name Each PreToolUse Guard in the Installer Docstring Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host-setup/agent-safety/claude/install.py b/host-setup/agent-safety/claude/install.py index 83e3ba452..eff0c2679 100755 --- a/host-setup/agent-safety/claude/install.py +++ b/host-setup/agent-safety/claude/install.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Install the agent host-safety kit for the current user account. Cross-platform, idempotent. -Deploys the PreToolUse hooks, the write guard and the model-tier guard with its tier mapping, the +Deploys the PreToolUse write guard, the PreToolUse model-tier guard with its tier mapping, the SessionEnd stray-process sweep, and the tool-containment shell prefix. It registers the hooks in the user settings.json. On a host with a systemd user manager it also sets the prefix in that file's `env`. It merges the permission rules this kit owns into the same file. It renders the user