Skip to content

Commit dc0451a

Browse files
ptr727claude
andcommitted
Check That the Kit Is Wired In, Not Only That Its Bytes Are Right
`--report` compared the deployed hook and the CLAUDE.md blocks and never looked at settings.json, where the hook is registered and the permission rules live. A machine with every byte correct and the PreToolUse entry removed carries a complete, current, entirely inert kit, and every check here called it CURRENT. That is the worst verdict this tool can give, since the whole question it answers is whether the guard is in force on this machine. `registration_problems` now reads settings.json and reports an unregistered hook, a hook registered more than once, a missing managed permission rule, and a settings file that is absent or unreadable. `stampVersion` was written into every stamp, described in its own comment as the way a reader detects a format change, and never validated. A stamp from another format version now says so rather than being read as valid. Nine cases added, all failing against the previous code, including that re-running clears an unregistered hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bf5abb0 commit dc0451a

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

‎host-setup/agent-safety/install.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ def build_stamp(claude_home, installed):
224224
# Shape rather than presence: a partial write leaves keys missing, and a hand edit leaves a key holding the wrong type.
225225
# A key check alone passes `"source": "git"` and then raises inside the line that formats it, which is the crash it was added to prevent.
226226
STAMP_SHAPE= {
227+
"stampVersion": int,
227228
"host": dict,
228229
"source": dict,
229230
"payloadDigest": str,
@@ -242,6 +243,47 @@ def stamp_problems(stamp):
242243
out.append(f"{key} is missing")
243244
elifnotisinstance(stamp[key], want):
244245
out.append(f"{key} is {type(stamp[key]).__name__} where {want.__name__} is required")
246+
# The version carries the format rather than the content, so a mismatch either way is unreadable.
247+
# A newer stamp holds fields this code does not know, and an older one lacks fields it reads.
248+
# Carrying the field and never checking it is the version telling nobody anything.
249+
ifstamp.get("stampVersion") notin (None, STAMP_VERSION) andisinstance(stamp.get("stampVersion"), int):
250+
out.append(f"stampVersion is {stamp['stampVersion']} where this installer writes {STAMP_VERSION}")
251+
returnout
252+
253+
254+
defregistration_problems(claude_home):
255+
"""Whether settings.json still wires the kit in, which decides if any of it actually runs.
256+
257+
The hook's bytes being correct says nothing about whether Claude Code invokes it. An entry
258+
removed from settings.json leaves a machine carrying a complete, current, and entirely inert
259+
kit, which every other check here reports as fine.
260+
"""
261+
settings=claude_home/"settings.json"
262+
ifnotsettings.is_file():
263+
return ["settings.json is missing, so the hook is not registered"]
264+
try:
265+
data=json.loads(settings.read_text(encoding="utf-8") or"{}")
266+
except (json.JSONDecodeError, OSError) ase:
267+
return [f"settings.json cannot be read ({e})"]
268+
ifnotisinstance(data, dict):
269+
return ["settings.json does not hold an object at its root"]
270+
out= []
271+
groups=data.get("hooks", {}).get("PreToolUse") ifisinstance(data.get("hooks"), dict) elseNone
272+
registered=0
273+
forgroupingroupsor []:
274+
ifnotisinstance(group, dict):
275+
continue
276+
forhookingroup.get("hooks") or []:
277+
ifisinstance(hook, dict) and"gh-write-guard"instr(hook.get("command", "")):
278+
registered+=1
279+
ifregistered==0:
280+
out.append("the PreToolUse hook is not registered in settings.json, so the guard never runs")
281+
elifregistered>1:
282+
out.append(f"the PreToolUse hook is registered {registered} times, so it runs more than once")
283+
allow=data.get("permissions", {}).get("allow") ifisinstance(data.get("permissions"), dict) elseNone
284+
for_, ruleinMANAGED_PERMISSIONS:
285+
ifnotisinstance(allow, list) orrulenotinallow:
286+
out.append(f"the permission rule {rule} is absent from settings.json")
245287
returnout
246288

247289

@@ -303,6 +345,8 @@ def report(claude_home):
303345
problems.append("the deployed hook or CLAUDE.md is missing, so the kit is not fully installed")
304346
eliflive_installed!=current:
305347
problems.append("the installed content differs from what this checkout would write")
348+
# Correct bytes on disk are not a running guard, so the wiring is checked as well.
349+
problems.extend(registration_problems(claude_home))
306350
iflive!=stamp.get("blocks"):
307351
problems.append(f"CLAUDE.md now holds {liveor'no blocks'}, where the stamp recorded {stamp.get('blocks') or'none'}")
308352
ifstamp.get("source", {}).get("dirty"):

‎host-setup/agent-safety/test_install.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,82 @@ def test_the_formatter_stays_printable_on_a_stamp_the_validator_would_reject(sel
241241
self.assertIsInstance(install.stamp_line(broken), str)
242242

243243

244+
classTestRegistration(StampCase):
245+
"""Correct bytes on disk are not a running guard. These are the inert-kit cases."""
246+
247+
def_settings(self):
248+
returnjson.loads((self.home/"settings.json").read_text(encoding="utf-8"))
249+
250+
def_write(self, data):
251+
(self.home/"settings.json").write_text(json.dumps(data, indent=2) +"\n", encoding="utf-8")
252+
253+
deftest_an_unregistered_hook_reports_stale_rather_than_current(self):
254+
"""Every byte is correct and the guard never runs, which every other check calls fine."""
255+
self.install()
256+
data=self._settings()
257+
data["hooks"]["PreToolUse"] = []
258+
self._write(data)
259+
r=run(self.home, "--report")
260+
self.assertEqual(r.returncode, 1, r.stdout+r.stderr)
261+
self.assertIn("never runs", r.stdout)
262+
263+
deftest_a_removed_permission_rule_reports_stale(self):
264+
self.install()
265+
data=self._settings()
266+
data["permissions"]["allow"] = []
267+
self._write(data)
268+
r=run(self.home, "--report")
269+
self.assertEqual(r.returncode, 1, r.stdout+r.stderr)
270+
self.assertIn("permission rule", r.stdout)
271+
272+
deftest_a_duplicated_hook_registration_reports_stale(self):
273+
self.install()
274+
data=self._settings()
275+
group=data["hooks"]["PreToolUse"][0]
276+
group["hooks"].append(dict(group["hooks"][0]))
277+
self._write(data)
278+
r=run(self.home, "--report")
279+
self.assertEqual(r.returncode, 1, r.stdout+r.stderr)
280+
self.assertIn("more than once", r.stdout)
281+
282+
deftest_a_deleted_settings_file_reports_stale_rather_than_crashing(self):
283+
self.install()
284+
(self.home/"settings.json").unlink()
285+
r=run(self.home, "--report")
286+
self.assertEqual(r.returncode, 1, r.stdout+r.stderr)
287+
self.assertNotIn("Traceback", r.stderr)
288+
289+
deftest_reinstalling_clears_an_unregistered_hook(self):
290+
self.install()
291+
data=self._settings()
292+
data["hooks"]["PreToolUse"] = []
293+
self._write(data)
294+
self.assertEqual(run(self.home, "--report").returncode, 1)
295+
self.install()
296+
self.assertEqual(run(self.home, "--report").returncode, 0)
297+
298+
299+
classTestStampVersion(StampCase):
300+
deftest_a_stamp_from_a_different_format_version_is_rejected(self):
301+
"""The field exists so a shape change is detectable, which needs it to be read."""
302+
self.install()
303+
stamp=json.loads(self.stamp.read_text(encoding="utf-8"))
304+
stamp["stampVersion"] =install.STAMP_VERSION+1
305+
self.stamp.write_text(json.dumps(stamp) +"\n", encoding="utf-8")
306+
r=run(self.home, "--report")
307+
self.assertEqual(r.returncode, 2, r.stdout+r.stderr)
308+
self.assertIn("stampVersion", r.stderr)
309+
310+
deftest_a_stamp_version_of_the_wrong_type_is_rejected(self):
311+
self.install()
312+
stamp=json.loads(self.stamp.read_text(encoding="utf-8"))
313+
stamp["stampVersion"] ="1"
314+
self.stamp.write_text(json.dumps(stamp) +"\n", encoding="utf-8")
315+
r=run(self.home, "--report")
316+
self.assertEqual(r.returncode, 2, r.stdout+r.stderr)
317+
self.assertIn("stampVersion", r.stderr)
318+
319+
244320
classTestStampContent(StampCase):
245321
deftest_the_stamp_names_the_machine_the_source_and_what_was_installed(self):
246322
self.install()

0 commit comments

Comments
 (0)