diff --git a/host-setup/agent-safety/claude/install.py b/host-setup/agent-safety/claude/install.py index d5b24d5b4..eff0c2679 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 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 +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 @@ -959,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. @@ -1094,8 +1095,8 @@ def judge_sweep(group, hook): groups, GUARD_NAME, ("Bash",), - "guard", - "the PreToolUse hook", + "write guard", + "the write guard", "that group never fires", "the guard never runs", ) @@ -1527,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 @@ -1539,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" ) 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"] = []