Skip to content

Commit 619098a

Browse files
feat(hooks): ground-truth mandatory-skill check in agent_persona dispatch gate
Closes the last gap in the hollow-persona hardening: a persona block could be internally self-consistent (every declared skill has a matching SKILL.md read path) yet still wrong -- missing a real mandatory skill for the named agent, or listing the wrong ones entirely. The prior format-only checks had no way to catch this since they never knew what an agent's actual skill set was. check_agent_persona now cross-checks a format-valid persona block against knowledge-graph/_master/agents_all.json: if the block names a real library agent but the declared skills: list omits one of that agent's real mandatory_skills, the dispatch is HARD BLOCKED with the exact missing skill(s) named. Extra optional skills beyond the mandatory set are always allowed (superset check). Deliberately fail-open: this hook runs globally across every project via ~/.claude/settings.json, not just ones using claude-global-library. If the registry can't be located (tries GLOBAL_LIBRARY_PATH env var, then the 'WORKING DIRECTORY & LIBRARY PATH:' line ORCHESTRATION_TEMPLATE.md mandates in every dispatch prompt) or the named agent isn't in it (e.g. a legitimate project-local custom persona), the dispatch is allowed through on the existing format checks alone. 11 new tests (35/35 total pass): mandatory-skill-missing block, complete pass, extra-optional-skills-allowed, fail-open on unknown agent, fail-open on unresolvable registry, escape hatch still bypasses, plus direct _resolve_agents_registry coverage (env var, WORKING DIRECTORY line, malformed JSON, nothing resolves). Also live-verified end-to-end against claude-global-library's real agents_all.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent db86ace commit 619098a

2 files changed

Lines changed: 267 additions & 4 deletions

File tree

‎hooks/pre_tool_enforcer/policies/agent_persona.py‎

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# and block persona spawns that name the agent but never actually carry its skills.
44
# Windows-safe: ASCII only, no Unicode characters.
55

6+
import json
7+
import os
68
import re
79

810
_GENERIC_TYPES = ("general-purpose", "claude", "")
@@ -39,6 +41,15 @@
3941
# matching stray single words or YAML punctuation inside the skills: value.
4042
_SKILL_TOKEN_RE = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)+")
4143

44+
# 'agent: <name>' line inside the persona block.
45+
_AGENT_NAME_RE = re.compile(r"^\s*agent\s*:\s*([a-z0-9][a-z0-9\-]*)\s*$", re.IGNORECASE | re.MULTILINE)
46+
47+
# The mandated 'WORKING DIRECTORY & LIBRARY PATH: <path>. ...' line every
48+
# dispatch prompt is supposed to start with (ORCHESTRATION_TEMPLATE.md GLOBAL
49+
# LIBRARY PATH MANDATE). Captures the path up to the first '. ' sentence
50+
# break -- real library paths never contain a literal '. ' sequence.
51+
_LIBRARY_PATH_LINE_RE = re.compile(r"WORKING DIRECTORY\s*&\s*LIBRARY PATH\s*:\s*([^\n]+?)(?:\.\s|\n|$)", re.IGNORECASE)
52+
4253
_BLOCK_MSG_NO_PERSONA = (
4354
"[PRE-TOOL BLOCKED] Generic subagent spawned without a library persona!\n"
4455
" Tool : Agent/Task spawn with subagent_type=general-purpose (or unset)\n"
@@ -75,6 +86,80 @@
7586
" agent.md Skill Dependencies section first)."
7687
)
7788

89+
_BLOCK_MSG_INCOMPLETE_SKILLS = (
90+
"[PRE-TOOL BLOCKED] Persona's declared skills do not match its real mandatory set!\n"
91+
" Tool : Agent/Task spawn with subagent_type=general-purpose (or unset)\n"
92+
" Agent : {agent_name}\n"
93+
" Problem : knowledge-graph/_master/agents_all.json lists this agent's mandatory\n"
94+
" skills as: {mandatory}\n"
95+
" but the persona block's 'skills:' field is missing: {missing}\n"
96+
" Required : The 'skills:' field must include EVERY mandatory skill for this\n"
97+
" agent (plus any task-relevant optional ones) -- a self-consistent\n"
98+
" but incomplete or wrong skill list is still a hollow dispatch, just\n"
99+
" one that passes a naive format check. Copy the mandatory list above\n"
100+
" into 'skills:' and add a matching 'skills/<name>/SKILL.md' read\n"
101+
" instruction for each.\n"
102+
" Escape hatch: prefix the prompt/description with [GENERIC-OK] only if this is a\n"
103+
" deliberate, reduced-scope dispatch that genuinely does not need the\n"
104+
" full mandatory set for this specific subtask."
105+
)
106+
107+
108+
def _resolve_agents_registry(prompt):
109+
"""Best-effort locate and parse knowledge-graph/_master/agents_all.json.
110+
111+
Tries the GLOBAL_LIBRARY_PATH environment variable first, then the
112+
'WORKING DIRECTORY & LIBRARY PATH: <path>' line ORCHESTRATION_TEMPLATE.md
113+
mandates at the top of every dispatch prompt. Returns the parsed list of
114+
agent records, or None if no candidate path resolves to a readable,
115+
parseable registry.
116+
117+
This hook runs globally across every project, not just projects that use
118+
claude-global-library -- callers MUST treat None as fail-open (skip the
119+
ground-truth check entirely), never as a reason to block. A project that
120+
does not use this library, or whose library copy is unreachable for some
121+
incidental reason, must not have its dispatches blocked by an
122+
infrastructure lookup failure.
123+
"""
124+
candidates = []
125+
env_path = os.environ.get("GLOBAL_LIBRARY_PATH")
126+
if env_path:
127+
candidates.append(env_path)
128+
path_match = _LIBRARY_PATH_LINE_RE.search(prompt)
129+
if path_match:
130+
candidates.append(path_match.group(1).strip().strip('"').strip("'"))
131+
132+
for base in candidates:
133+
try:
134+
registry_path = os.path.join(base, "knowledge-graph", "_master", "agents_all.json")
135+
if not os.path.isfile(registry_path):
136+
continue
137+
with open(registry_path, "r", encoding="utf-8") as f:
138+
data = json.load(f)
139+
except Exception:
140+
continue
141+
agents = data.get("agents") if isinstance(data, dict) else data
142+
if isinstance(agents, list) and agents:
143+
return agents
144+
return None
145+
146+
147+
def _find_agent_record(agents, name):
148+
"""Return the agent record whose 'name' field matches (case-insensitive),
149+
or None if not found. `agents` is the list _resolve_agents_registry returns.
150+
"""
151+
lowered = name.strip().lower()
152+
for record in agents:
153+
if isinstance(record, dict) and str(record.get("name", "")).strip().lower() == lowered:
154+
return record
155+
return None
156+
157+
158+
def _extract_agent_name(persona_block_text):
159+
"""Return the 'agent: <name>' value from a persona block, or None."""
160+
match = _AGENT_NAME_RE.search(persona_block_text)
161+
return match.group(1).strip().lower() if match else None
162+
78163

79164
def _extract_declared_skills(persona_block_text):
80165
"""Return the deduped, lowercased skill slugs listed in a persona block's
@@ -105,15 +190,29 @@ def check_agent_persona(tool_name, tool_input):
105190
A general-purpose (or unset) subagent_type must carry a library agent
106191
persona injected as a '---persona---' YAML block at the top of its
107192
prompt, per the Subagent Dispatch Contract -- and that block must
108-
actually carry the agent's skills, not just its name. Two failure modes
109-
are gated:
193+
actually carry the agent's skills, not just its name. Three failure
194+
modes are gated:
110195
1. No '---persona---' block at all (the original check).
111196
2. A '---persona---' block present but hollow -- no 'skills:' field,
112197
an empty one, or skills declared with no matching
113198
'skills/<name>/SKILL.md' read instruction in the prompt body. A
114199
persona shell without its skill knowledge defeats the whole point
115200
of persona injection: the subagent never actually reads the
116201
domain skill it is supposed to apply.
202+
3. A '---persona---' block that is internally self-consistent (every
203+
declared skill has a matching read path) but still WRONG -- it
204+
names a real library agent yet omits one or more of that agent's
205+
actual mandatory skills per knowledge-graph/_master/agents_all.json.
206+
Checks 1-2 only prove the block is well-formed; this check proves
207+
the skill list is the agent's real one, not just a plausible-looking
208+
one. This check is fail-open: if the agents_all.json registry
209+
cannot be located for the current project (see
210+
_resolve_agents_registry), or the named agent is not found in it
211+
(e.g. a legitimate project-local custom persona this library does
212+
not track), the dispatch is allowed through on checks 1-2 alone --
213+
this hook runs globally across every project, not just ones that
214+
use claude-global-library, and a registry-lookup failure must never
215+
become a false-positive block.
117216
118217
Named built-in subagent types (e.g. Explore, Plan) are never gated. An
119218
explicit '[GENERIC-OK]' marker in the prompt/description is an escape
@@ -182,4 +281,21 @@ def check_agent_persona(tool_name, tool_input):
182281
"'skills/<name>/SKILL.md' read instruction in the prompt: " + ", ".join(missing)
183282
)
184283

284+
agents = _resolve_agents_registry(prompt)
285+
if agents is not None:
286+
agent_name = _extract_agent_name(block_match.group(1))
287+
if agent_name is not None:
288+
record = _find_agent_record(agents, agent_name)
289+
if record is not None:
290+
mandatory = record.get("mandatory_skills")
291+
if isinstance(mandatory, list) and mandatory:
292+
declared_set = set(declared_skills)
293+
missing_mandatory = [s for s in mandatory if s.lower() not in declared_set]
294+
if missing_mandatory:
295+
return True, _BLOCK_MSG_INCOMPLETE_SKILLS.format(
296+
agent_name=agent_name,
297+
mandatory=", ".join(mandatory),
298+
missing=", ".join(missing_mandatory),
299+
)
300+
185301
return False, ""

‎tests/test_agent_persona_policy.py‎

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66
spawns that lack an injected library persona, blocks a '---persona---'
77
block that names an agent but never actually carries its skills (empty
88
skills: field, or declared skills with no matching SKILL.md read path),
9-
allows named built-in subagent types, allows the '[GENERIC-OK]' escape
10-
hatch, and never raises on malformed input.
9+
blocks a self-consistent-but-incomplete persona block that omits a real
10+
mandatory skill for a known library agent (ground-truth cross-check
11+
against a knowledge-graph/_master/agents_all.json registry, fail-open when
12+
that registry cannot be located -- this hook runs globally, not just in
13+
projects that use claude-global-library), allows named built-in
14+
subagent types, allows the '[GENERIC-OK]' escape hatch, and never raises
15+
on malformed input.
1116
1217
Windows-safe: ASCII only, no Unicode characters.
1318
"""
@@ -274,3 +279,145 @@ def test_escape_hatch_still_bypasses_hollow_persona_check(self):
274279
)
275280
assert blocked is False
276281
assert msg == ""
282+
283+
284+
_FAKE_REGISTRY = [
285+
{
286+
"name": "hallucination-detector",
287+
"mandatory_skills": ["hallucination-detection-core", "uncertainty-quantification-core"],
288+
},
289+
{
290+
"name": "orchestrator-agent",
291+
"mandatory_skills": ["ai-agents-core", "prompt-engineering-core", "system-design"],
292+
},
293+
]
294+
295+
296+
def _compliant_prompt_for(agent_name, skills):
297+
skills_yaml = ", ".join(skills)
298+
reads = "\n".join("- skills/{0}/SKILL.md".format(s) for s in skills)
299+
return (
300+
"---persona---\n"
301+
"agent: {0}\n"
302+
"skills: [{1}]\n"
303+
"---\n"
304+
"READ these skill files first:\n"
305+
"{2}\n"
306+
"TASK: do the thing"
307+
).format(agent_name, skills_yaml, reads)
308+
309+
310+
class TestGroundTruthMandatorySkillCheck:
311+
"""Covers failure mode 3: a persona block that is internally
312+
self-consistent (checks 1-2 pass) but omits a real mandatory skill for
313+
a known library agent, per a (monkeypatched) agents_all.json registry.
314+
"""
315+
316+
def test_blocks_when_registry_agent_missing_a_mandatory_skill(self, monkeypatch):
317+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: _FAKE_REGISTRY)
318+
prompt = _compliant_prompt_for("hallucination-detector", ["hallucination-detection-core"])
319+
blocked, msg = agent_persona.check_agent_persona(
320+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
321+
)
322+
assert blocked is True
323+
assert "uncertainty-quantification-core" in msg
324+
assert "hallucination-detector" in msg
325+
326+
def test_allows_when_registry_agent_has_all_mandatory_skills(self, monkeypatch):
327+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: _FAKE_REGISTRY)
328+
prompt = _compliant_prompt_for(
329+
"hallucination-detector", ["hallucination-detection-core", "uncertainty-quantification-core"]
330+
)
331+
blocked, msg = agent_persona.check_agent_persona(
332+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
333+
)
334+
assert blocked is False
335+
assert msg == ""
336+
337+
def test_allows_extra_optional_skills_beyond_mandatory_set(self, monkeypatch):
338+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: _FAKE_REGISTRY)
339+
prompt = _compliant_prompt_for(
340+
"hallucination-detector",
341+
["hallucination-detection-core", "uncertainty-quantification-core", "rag-faithfulness-core"],
342+
)
343+
blocked, msg = agent_persona.check_agent_persona(
344+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
345+
)
346+
assert blocked is False
347+
assert msg == ""
348+
349+
def test_fails_open_when_agent_name_not_in_registry(self, monkeypatch):
350+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: _FAKE_REGISTRY)
351+
prompt = _compliant_prompt_for("some-project-local-custom-agent", ["some-custom-skill-core"])
352+
blocked, msg = agent_persona.check_agent_persona(
353+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
354+
)
355+
assert blocked is False
356+
assert msg == ""
357+
358+
def test_fails_open_when_registry_cannot_be_resolved(self, monkeypatch):
359+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: None)
360+
prompt = _compliant_prompt_for("hallucination-detector", ["hallucination-detection-core"])
361+
blocked, msg = agent_persona.check_agent_persona(
362+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
363+
)
364+
assert blocked is False
365+
assert msg == ""
366+
367+
def test_escape_hatch_bypasses_mandatory_skill_check_too(self, monkeypatch):
368+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: _FAKE_REGISTRY)
369+
prompt = "[GENERIC-OK] " + _compliant_prompt_for("hallucination-detector", ["hallucination-detection-core"])
370+
blocked, msg = agent_persona.check_agent_persona(
371+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
372+
)
373+
assert blocked is False
374+
assert msg == ""
375+
376+
def test_real_filesystem_lookup_with_no_env_var_or_path_line_fails_open(self):
377+
# No monkeypatch here -- exercises the real _resolve_agents_registry
378+
# against the compliant prompt fixture, which carries neither
379+
# GLOBAL_LIBRARY_PATH nor a WORKING DIRECTORY & LIBRARY PATH line.
380+
blocked, msg = agent_persona.check_agent_persona(
381+
"Agent", {"subagent_type": "general-purpose", "prompt": _COMPLIANT_PROMPT}
382+
)
383+
assert blocked is False
384+
assert msg == ""
385+
386+
387+
class TestResolveAgentsRegistry:
388+
def test_resolves_via_env_var(self, tmp_path, monkeypatch):
389+
kg_dir = tmp_path / "knowledge-graph" / "_master"
390+
kg_dir.mkdir(parents=True)
391+
(kg_dir / "agents_all.json").write_text(
392+
'{"agents": [{"name": "foo-agent", "mandatory_skills": ["bar-core"]}]}',
393+
encoding="utf-8",
394+
)
395+
monkeypatch.setenv("GLOBAL_LIBRARY_PATH", str(tmp_path))
396+
agents = agent_persona._resolve_agents_registry("irrelevant prompt text")
397+
assert agents == [{"name": "foo-agent", "mandatory_skills": ["bar-core"]}]
398+
399+
def test_resolves_via_working_directory_line(self, tmp_path, monkeypatch):
400+
monkeypatch.delenv("GLOBAL_LIBRARY_PATH", raising=False)
401+
kg_dir = tmp_path / "knowledge-graph" / "_master"
402+
kg_dir.mkdir(parents=True)
403+
(kg_dir / "agents_all.json").write_text(
404+
'{"agents": [{"name": "foo-agent", "mandatory_skills": ["bar-core"]}]}',
405+
encoding="utf-8",
406+
)
407+
prompt = "WORKING DIRECTORY & LIBRARY PATH: {0}. Read from here.\nTASK: x".format(str(tmp_path))
408+
agents = agent_persona._resolve_agents_registry(prompt)
409+
assert agents == [{"name": "foo-agent", "mandatory_skills": ["bar-core"]}]
410+
411+
def test_returns_none_when_nothing_resolves(self, monkeypatch):
412+
monkeypatch.delenv("GLOBAL_LIBRARY_PATH", raising=False)
413+
agents = agent_persona._resolve_agents_registry("no path info here at all")
414+
assert agents is None
415+
416+
def test_returns_none_on_malformed_registry_json(self, tmp_path, monkeypatch):
417+
monkeypatch.delenv("GLOBAL_LIBRARY_PATH", raising=False)
418+
kg_dir = tmp_path / "knowledge-graph" / "_master"
419+
kg_dir.mkdir(parents=True)
420+
(kg_dir / "agents_all.json").write_text("{not valid json", encoding="utf-8")
421+
prompt = "WORKING DIRECTORY & LIBRARY PATH: {0}. Read from here.\nTASK: x".format(str(tmp_path))
422+
agents = agent_persona._resolve_agents_registry(prompt)
423+
assert agents is None

0 commit comments

Comments
 (0)