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) == []