From 89db78fa914a6f53024ecbd8dcbcd25fdd74ae5f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 11:23:48 -0700 Subject: [PATCH 1/2] Require Non-Empty Model Names in the Tier Config and Name Both Release Edits The validator now rejects a model value that is not a non-empty string, which the deployed guard would read as an unreadable mapping. The guard's docstring names the AGENTS.md table alongside spec/model-tiers.json as a model release's edits. The tier tests hold the models mapping as their own typed fixture rather than indexing through an untyped config. Co-Authored-By: Claude Opus 5.5 --- .../agent-safety/claude/model-tier-guard.py | 3 +- spec/validate.py | 5 ++++ tests/test_spec_validate.py | 28 ++++++++++--------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/host-setup/agent-safety/claude/model-tier-guard.py b/host-setup/agent-safety/claude/model-tier-guard.py index a2904872..c6b52bc7 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 that file and to the hub's AGENTS.md +tier table its spec gate holds equal to it, never to this hook. 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"] = [] From 07edec5d1b9afc3c82f963ddc7950c0f46ed0512 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 11:25:24 -0700 Subject: [PATCH 2/2] Split the Guard's Model Release Sentence Co-Authored-By: Claude Opus 5.5 --- host-setup/agent-safety/claude/model-tier-guard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/host-setup/agent-safety/claude/model-tier-guard.py b/host-setup/agent-safety/claude/model-tier-guard.py index c6b52bc7..86bbccea 100755 --- a/host-setup/agent-safety/claude/model-tier-guard.py +++ b/host-setup/agent-safety/claude/model-tier-guard.py @@ -6,8 +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 and to the hub's AGENTS.md -tier table its spec gate holds equal to it, never to this hook. +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