Add profile skill visibility policies
Extend _skills with sparse allow/block rules and an explicit default for future skills, using the existing layered plugin configuration and skill-entry normalization. Enforce the policy across discovery, loading, and chat activation while preserving loaded history, legacy hidden-skill behavior, and canonical name/path identity.
Alessandro committed
Aug 5, 2026 at 10:54 UTC
c2ee867665f774e6456a396c17cc2cd09a67f585
6 files changed
+244
-18
helpers/skills.py
+113
-6
@@ -39,6 +39,8 @@ class CatalogSkill(TypedDict):
39
path: str
40
origin: str
41
hidden: bool
42
+ tags: list[str]
43
+ allowed_tools: list[str]
44
45
46
@dataclass(slots=True)
@@ -739,9 +741,70 @@ def normalize_skills_config(config: dict[str, Any] | None) -> dict[str, Any]:
741
normalized["hidden_skills"] = normalize_hidden_skills(
742
normalized.get("hidden_skills")
743
)
744
+ if "visibility_policy" in normalized:
745
+ normalized["visibility_policy"] = normalize_visibility_policy(
746
+ normalized.get("visibility_policy")
747
+ )
748
return normalized
749
750
751
+def normalize_visibility_policy(raw: Any) -> dict[str, Any]:
752
+ policy = dict(raw) if isinstance(raw, dict) else {}
753
+ mode = str(policy.get("mode") or "inherit").strip().lower()
754
+ default = str(policy.get("default") or "allow").strip().lower()
755
+ policy["mode"] = "custom" if mode == "custom" else "inherit"
756
+ policy["default"] = "block" if default == "block" else "allow"
757
+ for key in ("allowed", "blocked"):
758
+ policy[key] = [
759
+ str(entry.get("name") or entry.get("path") or "")
760
+ for entry in normalize_hidden_skills(policy.get(key))
761
+ ]
762
+ return policy
763
+
764
+
765
+def get_visibility_policy(agent: Agent | None) -> dict[str, Any]:
766
+ if not agent:
767
+ return normalize_visibility_policy(None)
768
+ config = plugin_helpers.get_plugin_config(
769
+ ACTIVE_SKILLS_PLUGIN_NAME,
770
+ agent=agent,
771
+ ) or {}
772
+ return normalize_visibility_policy(config.get("visibility_policy"))
773
+
774
+
775
+def is_skill_allowed(
776
+ policy: dict[str, Any],
777
+ skill_or_entry: Skill | ActiveSkillEntry | str,
778
+) -> bool:
779
+ if policy["mode"] != "custom":
780
+ return True
781
+
782
+ aliases = _skill_visibility_aliases(skill_or_entry)
783
+ if any(
784
+ aliases & _skill_visibility_aliases(value)
785
+ for value in policy["blocked"]
786
+ ):
787
+ return False
788
+ if any(
789
+ aliases & _skill_visibility_aliases(value)
790
+ for value in policy["allowed"]
791
+ ):
792
+ return True
793
+ return policy["default"] == "allow"
794
+
795
+
796
+def ensure_skill_visible(agent: Agent, entry: ActiveSkillEntry | str) -> None:
797
+ if is_skill_allowed(get_visibility_policy(agent), entry):
798
+ return
799
+ name = (
800
+ str(entry.get("name") or entry.get("path") or "").strip()
801
+ if isinstance(entry, dict)
802
+ else str(entry or "").strip()
803
+ )
804
+ profile = str(getattr(getattr(agent, "config", None), "profile", "") or "default")
805
+ raise ValueError(f'Skill "{name}" is blocked for agent profile "{profile}".')
806
+
807
+
808
def normalize_active_skills(
809
raw: Any,
810
*,
@@ -795,6 +858,7 @@ def list_skill_catalog(
858
catalog: list[CatalogSkill] = []
859
seen_paths: set[str] = set()
860
hidden_entries = get_hidden_skills(agent) if agent else []
861
+ visibility_policy = get_visibility_policy(agent)
862
863
for root in _get_catalog_roots(project_name=project_name, agent=agent):
864
root_path = Path(root)
@@ -808,6 +872,7 @@ def list_skill_catalog(
872
continue
873
874
seen_paths.add(runtime_path)
875
+ allowed = is_skill_allowed(visibility_policy, skill)
876
catalog.append(
877
{
878
"name": skill.name or skill.path.name,
@@ -817,7 +882,11 @@ def list_skill_catalog(
882
runtime_path,
883
project_name=project_name,
884
),
820
- "hidden": _skill_matches_entries(skill, hidden_entries),
885
+ "hidden": _skill_matches_entries(
886
+ skill, hidden_entries
887
+ ) or not allowed,
888
+ "tags": list(skill.tags),
889
+ "allowed_tools": list(skill.allowed_tools),
890
}
891
)
892
@@ -932,12 +1001,16 @@ def _build_active_skills(
1001
current_hidden_entries,
1002
current_visible_entries,
1003
)
935
- return _merge_active_skill_entries(
1004
+ merged = _merge_active_skill_entries(
1005
scope_entries,
1006
current_chat_entries,
1007
effective_hidden_entries,
1008
limit=effective_limit,
1009
)
1010
+ visibility_policy = get_visibility_policy(agent)
1011
+ return [
1012
+ entry for entry in merged if is_skill_allowed(visibility_policy, entry)
1013
+ ]
1014
1015
1016
def get_active_skills(agent: Agent | None) -> list[ActiveSkillEntry]:
@@ -1040,6 +1113,7 @@ def activate_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
1113
normalized = _normalize_active_skill_entry(entry)
1114
if not normalized:
1115
raise ValueError("A skill name or path is required.")
1116
+ ensure_skill_visible(agent, normalized)
1117
1118
context = getattr(agent, "context", None)
1119
if not context:
@@ -1195,6 +1269,7 @@ def show_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
1269
normalized = _normalize_active_skill_entry(entry)
1270
if not normalized:
1271
raise ValueError("A skill name or path is required.")
1272
+ ensure_skill_visible(agent, normalized)
1273
1274
context = getattr(agent, "context", None)
1275
if not context:
@@ -1540,7 +1615,9 @@ def _skill_matches_entries(
1615
def _skill_is_hidden_for_agent(agent: Agent | None, skill: Skill) -> bool:
1616
if not agent:
1617
return False
1543
- return _skill_matches_entries(skill, get_hidden_skills(agent))
1618
+ return _skill_matches_entries(
1619
+ skill, get_hidden_skills(agent)
1620
+ ) or not is_skill_allowed(get_visibility_policy(agent), skill)
1621
1622
1623
def _filter_hidden_skills(
@@ -1551,8 +1628,38 @@ def _filter_hidden_skills(
1628
return skills
1629
1630
hidden_entries = get_hidden_skills(agent)
1554
- if not hidden_entries:
1555
- return skills
1631
+ visibility_policy = get_visibility_policy(agent)
1632
return [
1557
- skill for skill in skills if not _skill_matches_entries(skill, hidden_entries)
1633
+ skill
1634
+ for skill in skills
1635
+ if not _skill_matches_entries(skill, hidden_entries)
1636
+ and is_skill_allowed(visibility_policy, skill)
1637
]
1638
+
1639
+
1640
+def _skill_visibility_aliases(
1641
+ skill_or_entry: Skill | ActiveSkillEntry | str,
1642
+) -> set[str]:
1643
+ if isinstance(skill_or_entry, Skill):
1644
+ values = (
1645
+ skill_or_entry.name,
1646
+ skill_or_entry.path.name,
1647
+ files.normalize_a0_path(str(skill_or_entry.path)),
1648
+ )
1649
+ elif isinstance(skill_or_entry, dict):
1650
+ values = (
1651
+ str(skill_or_entry.get("name") or ""),
1652
+ str(skill_or_entry.get("path") or ""),
1653
+ )
1654
+ else:
1655
+ values = (str(skill_or_entry or ""),)
1656
+
1657
+ aliases: set[str] = set()
1658
+ for value in values:
1659
+ fixed = value.strip().replace("\\", "/").rstrip("/")
1660
+ if not fixed:
1661
+ continue
1662
+ aliases.add(fixed.casefold())
1663
+ if "/" in fixed:
1664
+ aliases.add(fixed.rsplit("/", 1)[-1].casefold())
1665
+ return aliases
helpers/skills.py.dox.md
+12
@@ -41,6 +41,10 @@
41
- `_normalize_max_active_skills(value: Any) -> int`
42
- `get_max_active_skills(agent: Agent | None=..., project_name: str | None=...) -> int`
43
- `normalize_skills_config(config: dict[str, Any] | None) -> dict[str, Any]`
44
+- `normalize_visibility_policy(raw: Any) -> dict[str, Any]`
45
+- `get_visibility_policy(agent: Agent | None) -> dict[str, Any]`
46
+- `is_skill_allowed(policy, skill_or_entry) -> bool`
47
+- `ensure_skill_visible(agent, entry) -> None`
48
- `normalize_active_skills(raw: Any, limit: int | None=...) -> list[ActiveSkillEntry]`
49
- `normalize_hidden_skills(raw: Any) -> list[ActiveSkillEntry]`
50
- `normalize_skill_entries(raw: Any, limit: int | None=...) -> list[ActiveSkillEntry]`
@@ -60,6 +64,14 @@
64
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
65
- Loaded skill names are chat-wide context data under `CONTEXT_DATA_NAME_LOADED_SKILLS`; legacy agent-local `loaded_skills` lists are migrated into context data and cleared when read.
66
- Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger.
67
+- `_skills.visibility_policy` is profile-aware and uses explicit future-skill
68
+ allow/block defaults. It filters discovery, search, new loading, chat
69
+ activation, and active-skill resolution without removing instructions already
70
+ stored in chat history.
71
+- Visibility policy IDs match both canonical skill paths and their directory
72
+ names, and bulk discovery resolves the effective policy only once.
73
+- Legacy `hidden_skills` remains default-allow with blocked exceptions; a chat
74
+ visibility override cannot bypass profile visibility policy.
75
- `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol.
76
- `search_skills()` normalizes query words, scores normal terms against skill names, and scores only long terms against tags/triggers; descriptions match only full query phrases so generic prose does not produce irrelevant suggestions.
77
- `find_skill(validate=False)` lets validation tooling resolve a skill with incomplete metadata while preserving runtime validation by default.
plugins/_skills/AGENTS.md
+7
-1
@@ -2,7 +2,8 @@
2
3
## Purpose
4
5
-- Own current-chat skill loading and hidden skill configuration.
5
+- Own current-chat skill loading, hidden skill configuration, and profile-level
6
+ visibility policy.
7
8
## Ownership
9
@@ -17,6 +18,11 @@
18
- Loaded skills are append-only from the user UI because their instructions live in chat history.
19
- Store configured skills in normalized portable paths.
20
- Hidden skills affect catalog/search/load visibility but must not remove loaded skill history.
21
+- A profile visibility policy has an explicit future-skill default. It limits
22
+ discovery and new loading without pinning skills or removing history-loaded
23
+ instructions.
24
+- Chat visibility overrides may reverse legacy `hidden_skills`, but cannot
25
+ re-enable a skill blocked by profile policy.
26
27
## Work Guidance
28
plugins/_skills/README.md
+5
-2
@@ -1,6 +1,7 @@
1
# Skills
2
3
-Skills is a built-in Agent Zero plugin that manages skill loading and visibility for the current chat.
3
+Skills is a built-in Agent Zero plugin that manages current-chat skill loading
4
+and layered skill visibility, including profile-level policy from Agent Editor.
5
6
## What It Does
7
@@ -8,7 +9,7 @@ Skills is a built-in Agent Zero plugin that manages skill loading and visibility
9
- hides noisy skills from the model-facing available catalog, skill search, and load access
10
- shows loaded skills without offering removal, because loaded skill bodies are part of chat history
11
- lets users hide or show skills live per conversation
11
-- supports global and project scoped configurations without agent-profile variants
12
+- supports global/project settings plus profile-level Agent Editor visibility policy
13
- links directly to the built-in Skills list
14
- links directly to the active project's Skills section when a project is active
15
@@ -26,3 +27,5 @@ The shared skill discovery and loaded-skill ledger live in `helpers/skills.py`,
27
- hidden skills are stored as control data, not injected into the prompt
28
- hidden skill paths are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts
29
- if a configured hidden skill is not visible in the current agent scope, it is skipped quietly instead of breaking catalog builds
30
+- profile visibility uses sparse Allowed/Blocked exceptions with an explicit
31
+ default for future skills; allowing a skill does not load or pin it
plugins/_skills/plugin.yaml
+1
-1
@@ -6,4 +6,4 @@ always_enabled: true
6
settings_sections:
7
- agent
8
per_project_config: true
9
-per_agent_config: false
9
+per_agent_config: true
tests/test_skills_runtime.py
+106
-8
@@ -124,6 +124,18 @@ def _scope_config(entries=None, *, hidden_entries=None, max_active_skills=None):
124
return config
125
126
127
+def _write_skill_catalog(root: Path, *names: str) -> Path:
128
+ skills_root = root / "skills"
129
+ for name in names:
130
+ skill_dir = skills_root / name
131
+ skill_dir.mkdir(parents=True)
132
+ (skill_dir / "SKILL.md").write_text(
133
+ f"---\nname: {name}\ndescription: {name} description\n---\nBody\n",
134
+ encoding="utf-8",
135
+ )
136
+ return skills_root
137
+
138
+
139
def test_active_skills_cap_is_twenty():
140
assert runtime.MAX_ACTIVE_SKILLS == 20
141
assert runtime.get_max_active_skills() == 20
@@ -671,14 +683,7 @@ def test_activating_new_skill_uses_scope_configured_limit(monkeypatch):
683
684
685
def test_hidden_skills_filter_agent_visible_skill_catalog(monkeypatch, tmp_path: Path):
674
- skills_root = tmp_path / "skills"
675
- for name in ("alpha-skill", "beta-skill"):
676
- skill_dir = skills_root / name
677
- skill_dir.mkdir(parents=True)
678
- (skill_dir / "SKILL.md").write_text(
679
- f"---\nname: {name}\ndescription: {name} description\n---\nBody\n",
680
- encoding="utf-8",
681
- )
686
+ skills_root = _write_skill_catalog(tmp_path, "alpha-skill", "beta-skill")
687
688
monkeypatch.setattr(
689
runtime.subagents,
@@ -731,3 +736,96 @@ def test_chat_visible_override_restores_scope_hidden_skill(monkeypatch):
736
runtime.hide_chat_skill(agent, {"name": "beta-skill"})
737
assert runtime.get_hidden_skills(agent) == [{"name": "beta-skill"}]
738
assert runtime.get_chat_visible_skills(agent.context) == []
739
+
740
+
741
+def test_visibility_policy_is_absent_until_explicitly_configured():
742
+ normalized = runtime.normalize_skills_config({"hidden_skills": []})
743
+
744
+ assert "visibility_policy" not in normalized
745
+ assert runtime.normalize_visibility_policy(
746
+ {
747
+ "mode": "custom",
748
+ "default": "block",
749
+ "allowed": ["alpha", "alpha", {"name": "missing"}],
750
+ "blocked": [],
751
+ }
752
+ ) == {
753
+ "mode": "custom",
754
+ "default": "block",
755
+ "allowed": ["alpha", "missing"],
756
+ "blocked": [],
757
+ }
758
+
759
+
760
+def test_allow_only_visibility_blocks_new_skills_without_pinning(
761
+ monkeypatch, tmp_path: Path
762
+):
763
+ skills_root = _write_skill_catalog(tmp_path, "alpha-skill", "new-skill")
764
+
765
+ monkeypatch.setattr(
766
+ runtime.subagents,
767
+ "get_paths",
768
+ lambda agent, *parts: [str(skills_root)],
769
+ )
770
+ monkeypatch.setattr(runtime.files, "exists", lambda path: Path(str(path)) == skills_root)
771
+ monkeypatch.setattr(
772
+ runtime.plugin_helpers,
773
+ "get_plugin_config",
774
+ lambda *args, **kwargs: {
775
+ "visibility_policy": {
776
+ "mode": "custom",
777
+ "default": "block",
778
+ "allowed": ["alpha-skill"],
779
+ "blocked": [],
780
+ }
781
+ },
782
+ )
783
+ agent = DummyAgent()
784
+
785
+ assert [skill.name for skill in runtime.list_skills(agent)] == ["alpha-skill"]
786
+ assert runtime.find_skill("new-skill", agent=agent) is None
787
+ assert runtime.load_skill_for_agent("new-skill", agent=agent) == (
788
+ "Error: skill 'new-skill' not found"
789
+ )
790
+ assert runtime.get_active_skills(agent) == []
791
+
792
+ catalog = {item["name"]: item for item in runtime.list_skill_catalog(agent=agent)}
793
+ assert catalog["alpha-skill"]["hidden"] is False
794
+ assert catalog["new-skill"]["hidden"] is True
795
+
796
+
797
+def test_profile_blocked_skill_cannot_be_reenabled_by_chat_override(monkeypatch):
798
+ monkeypatch.setattr(
799
+ runtime.plugin_helpers,
800
+ "get_plugin_config",
801
+ lambda *args, **kwargs: {
802
+ "visibility_policy": {
803
+ "mode": "custom",
804
+ "default": "allow",
805
+ "allowed": [],
806
+ "blocked": ["beta-skill"],
807
+ }
808
+ },
809
+ )
810
+ agent = DummyAgent()
811
+
812
+ with pytest.raises(ValueError, match='Skill "beta-skill" is blocked'):
813
+ runtime.activate_chat_skill(agent, {"name": "beta-skill"})
814
+ with pytest.raises(ValueError, match='Skill "beta-skill" is blocked'):
815
+ runtime.show_chat_skill(agent, {"name": "beta-skill"})
816
+ with pytest.raises(ValueError, match="is blocked"):
817
+ runtime.activate_chat_skill(
818
+ agent, {"path": "/a0/skills/beta-skill"}
819
+ )
820
+ with pytest.raises(ValueError, match="is blocked"):
821
+ runtime.show_chat_skill(agent, {"path": "/a0/skills/beta-skill"})
822
+
823
+ agent.context.set_data(
824
+ runtime.CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS,
825
+ [{"name": "beta-skill"}],
826
+ )
827
+ agent.context.set_data(
828
+ runtime.CONTEXT_DATA_NAME_CHAT_ACTIVE_SKILLS,
829
+ [{"path": "/a0/skills/beta-skill"}],
830
+ )
831
+ assert runtime.get_active_skills(agent) == []