diff --git a/host-setup/agent-safety/claude/model-tier-guard.py b/host-setup/agent-safety/claude/model-tier-guard.py index a2904872..86bbccea 100755 --- a/host-setup/agent-safety/claude/model-tier-guard.py +++ b/host-setup/agent-safety/claude/model-tier-guard.py @@ -6,7 +6,8 @@ `model-tiers.json` marks maintainer-only, today `highest`, so `fable`. Everything else passes. The mapping is read from `model-tiers.json` beside this file, which the installer copies from the -hub's spec/model-tiers.json. A model release is then an edit to that file alone. +hub's spec/model-tiers.json. A model release is then an edit to the hub's spec/model-tiers.json and +to its AGENTS.md tier table, never to this hook. The hub's spec gate holds the two equal. The maintainer allows the tier for a session by launching it with FLEET_ALLOW_HIGHEST=1 in the environment. A hook runs as its own process, so an agent cannot set it for itself, and the deny diff --git a/spec/validate.py b/spec/validate.py index ac352432..f6966016 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -735,6 +735,11 @@ def model_tier_errors(root): f"spec/model-tiers.json: tool {tool!r} must map exactly the declared tiers" ) continue + if not all(isinstance(m, str) and m for m in models.values()): + errors.append( + f"spec/model-tiers.json: tool {tool!r} must map each tier to a non-empty model name" + ) + continue rows.append(f"| {entry['name']} | " + " | ".join(f"`{models[t]}`" for t in tiers) + " |") agents_path = root / "AGENTS.md" if not agents_path.is_file(): diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index af532952..c2e2bb42 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -351,21 +351,17 @@ class ModelTierCase(unittest.TestCase): def setUp(self) -> None: self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) (self.root / "spec").mkdir() + self.models: dict[str, object] = { + "highest": "fable", + "frontier": "opus", + "standard": "sonnet", + "bulk": "haiku", + } self.config: dict[str, object] = { "tiers": ["highest", "frontier", "standard", "bulk"], "maintainerOnly": "highest", "defaultTier": "frontier", - "tools": { - "claude-code": { - "name": "Claude Code", - "models": { - "highest": "fable", - "frontier": "opus", - "standard": "sonnet", - "bulk": "haiku", - }, - } - }, + "tools": {"claude-code": {"name": "Claude Code", "models": self.models}}, } def errors(self, table: str = TABLE) -> list[str]: @@ -390,11 +386,17 @@ def test_rejects_the_maintainer_only_tier_as_the_default(self) -> None: self.assertTrue(any("defaultTier" in e for e in self.errors())) def test_rejects_a_tool_missing_a_tier(self) -> None: - tool = self.config["tools"]["claude-code"] # type: ignore[index] - del tool["models"]["bulk"] + del self.models["bulk"] self.assertTrue(any("exactly the declared tiers" in e for e in self.errors())) + def test_rejects_a_model_that_is_not_a_non_empty_string(self) -> None: + for value in (None, 7, ""): + with self.subTest(value=value): + self.models["highest"] = value + + self.assertTrue(any("non-empty model name" in e for e in self.errors())) + def test_rejects_a_tools_list_instead_of_an_object(self) -> None: self.config["tools"] = []