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
23 changes: 12 additions & 11 deletions host-setup/agent-safety/claude/install.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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
Expand All @@ -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"
)

Expand Down
2 changes: 2 additions & 0 deletions spec/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions tests/test_spec_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = []

Expand Down
Loading