Skip to content

Commit 3d498be

Browse files
fix: agent_persona hook silently drops hyphen-less skill names like docker
_SKILL_TOKEN_RE required at least one hyphen in every skill token (quantifier was '+', one-or-more), so single-word skill names such as 'docker' and 'kubernetes' -- both real, currently-shipping skills in claude-global-library -- were silently dropped from the parsed declared-skills list even when present verbatim in a persona block's skills: field. This caused the PreToolUse hook to HARD BLOCK correct Agent/Task dispatches with a misleading "missing: docker" message. Fix: change the quantifier to '*' (zero-or-more) so a bare hyphen-less word still matches. The regex is only ever applied to the already- isolated skills: field value, so the broadened match carries no risk of picking up unrelated words elsewhere in a dispatch prompt. Added a regression test class (TestHyphenLessSkillNamesAreNotDropped) covering token extraction and the full dispatch-allow/dispatch-block paths for a hyphen-less mandatory skill, since none of the existing 38 tests exercised this case -- which is exactly how the bug shipped unnoticed. Fixes #314. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZrJrDJYy6x6bJFvQrpuJi
1 parent c82ee38 commit 3d498be

2 files changed

Lines changed: 58 additions & 4 deletions

File tree

‎hooks/pre_tool_enforcer/policies/agent_persona.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,16 @@
3636
# ('skills:\n - a\n - b') forms used across real dispatch prompts.
3737
_SKILLS_FIELD_RE = re.compile(r"skills\s*:\s*(.*?)(?:\n[ \t]*[A-Za-z_][\w-]*\s*:|\Z)", re.DOTALL | re.IGNORECASE)
3838

39-
# A skill slug: lowercase, at least one hyphen (every real skill in this
40-
# library is named like 'ai-agents-core' / 'system-design') -- this avoids
41-
# matching stray single words or YAML punctuation inside the skills: value.
42-
_SKILL_TOKEN_RE = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)+")
39+
# A skill slug: lowercase, hyphen-separated words with zero or more hyphens.
40+
# Most real skills in this library are named like 'ai-agents-core' /
41+
# 'system-design', but a few genuinely have no hyphen at all (e.g. 'docker',
42+
# 'kubernetes') -- the quantifier below must be zero-or-more ('*'), not
43+
# one-or-more ('+'), or those skills are silently dropped from the parsed
44+
# declared-skills list even when present verbatim in the skills: field. This
45+
# regex is only ever applied to the already-isolated skills: field capture
46+
# (via _SKILLS_FIELD_RE), so broadening it does not risk matching stray
47+
# words elsewhere in a dispatch prompt.
48+
_SKILL_TOKEN_RE = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*")
4349

4450
# 'agent: <name>' line inside the persona block.
4551
_AGENT_NAME_RE = re.compile(r"^\s*agent\s*:\s*([a-z0-9][a-z0-9\-]*)\s*$", re.IGNORECASE | re.MULTILINE)

‎tests/test_agent_persona_policy.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,57 @@ def test_escape_hatch_still_bypasses_hollow_persona_check(self):
295295
"mandatory_skills": ["iam-security-core", "zero-trust-architecture-core"],
296296
"cross_domain_mandatory_skills": ["authentication-core"],
297297
},
298+
{
299+
"name": "python-backend-engineer",
300+
# 'docker' has zero hyphens -- this is the exact real-library shape
301+
# that regressed _SKILL_TOKEN_RE (issue: single-word skill names were
302+
# silently dropped from the parsed declared-skills list).
303+
"mandatory_skills": ["fastapi-core", "docker", "rdbms-core"],
304+
},
298305
]
299306

300307

308+
class TestHyphenLessSkillNamesAreNotDropped:
309+
"""Regression coverage for a real bug: _SKILL_TOKEN_RE required at least
310+
one hyphen in every skill token, so single-word skill names like
311+
'docker' or 'kubernetes' -- both real, currently-shipping skills in
312+
claude-global-library -- were silently dropped from the parsed
313+
declared-skills list even when present verbatim in the persona block's
314+
skills: field, causing a false INCOMPLETE_SKILLS block. See the fixed
315+
_SKILL_TOKEN_RE quantifier ('*' not '+') above.
316+
"""
317+
318+
def test_extract_declared_skills_includes_hyphen_less_token(self):
319+
block_text = "\nagent: python-backend-engineer\nskills: [fastapi-core, docker, rdbms-core]\n"
320+
declared = agent_persona._extract_declared_skills(block_text)
321+
assert "docker" in declared, "hyphen-less skill token 'docker' must not be dropped"
322+
assert declared == ["fastapi-core", "docker", "rdbms-core"]
323+
324+
def test_extract_declared_skills_includes_kubernetes(self):
325+
block_text = "\nskills: [nosql-core, kubernetes, redis-core]\n"
326+
declared = agent_persona._extract_declared_skills(block_text)
327+
assert "kubernetes" in declared
328+
329+
def test_allows_dispatch_when_hyphen_less_mandatory_skill_is_declared(self, monkeypatch):
330+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: _FAKE_REGISTRY)
331+
prompt = _compliant_prompt_for("python-backend-engineer", ["fastapi-core", "docker", "rdbms-core"])
332+
blocked, msg = agent_persona.check_agent_persona(
333+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
334+
)
335+
assert blocked is False, "correctly-declared hyphen-less mandatory skill must not block dispatch: " + msg
336+
assert msg == ""
337+
338+
def test_still_blocks_when_hyphen_less_mandatory_skill_is_genuinely_missing(self, monkeypatch):
339+
monkeypatch.setattr(agent_persona, "_resolve_agents_registry", lambda prompt: _FAKE_REGISTRY)
340+
# 'docker' genuinely omitted this time -- must still correctly block.
341+
prompt = _compliant_prompt_for("python-backend-engineer", ["fastapi-core", "rdbms-core"])
342+
blocked, msg = agent_persona.check_agent_persona(
343+
"Agent", {"subagent_type": "general-purpose", "prompt": prompt}
344+
)
345+
assert blocked is True
346+
assert "docker" in msg
347+
348+
301349
def _compliant_prompt_for(agent_name, skills):
302350
skills_yaml = ", ".join(skills)
303351
reads = "\n".join("- skills/{0}/SKILL.md".format(s) for s in skills)

0 commit comments

Comments
 (0)