Skip to content

Commit aa7395e

Browse files
feat: implement pluggable taxonomy and policy security plugin (#151)
* feat: implement pluggable taxonomy and policy security plugin * add: add test method model and skills * feat: wire description shaping, system prompt steering, and priority sorting * test: add AvatarConfig mock * feat: add steering hook such as descripiton, sys prompts. tool sorting to skillpolicy * test: add docstrings * feat: expose pre-built resolver and policy in package API * feat: add triggers and variables to TaxonomyTerm model * feat: implement DefaultKeywordResolver and DefaultSkillPolicy * feat: wire Plugin interceptors for skill and prompt tailoring * test: add coverage for flat JSON parsing and variable interpolation * refactor: add shape_skill hook, typing imports, and interpolation warning logs * feat: implement robust cross-platform path validation and delegate skill replication to policy hook * test: add unit tests for robust path safety, interpolation warnings, and custom shape_skill overrides * refactor: declare registry attribute on SkillPolicy interface * refactor: restore clean dot-notation for registry assignment
1 parent c47da11 commit aa7395e

7 files changed

Lines changed: 1258 additions & 1 deletion

File tree

src/google/adk_community/plugins/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,23 @@
1515
from google.adk_community.plugins.agent_governance_plugin import (
1616
AgentGovernancePlugin,
1717
)
18+
from google.adk_community.plugins.taxonomy import (
19+
DefaultSkillPolicy,
20+
SkillPolicy,
21+
TaxonomyPipeline,
22+
TaxonomyPlugin,
23+
TaxonomyRegistry,
24+
TaxonomyResolver,
25+
TaxonomyTerm,
26+
)
1827

19-
__all__ = ["AgentGovernancePlugin"]
28+
__all__ = [
29+
"AgentGovernancePlugin",
30+
"DefaultSkillPolicy",
31+
"SkillPolicy",
32+
"TaxonomyPipeline",
33+
"TaxonomyPlugin",
34+
"TaxonomyRegistry",
35+
"TaxonomyResolver",
36+
"TaxonomyTerm",
37+
]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Pluggable Policy & Taxonomy Security Engine for ADK Community."""
16+
17+
from .policy import DefaultSkillPolicy
18+
from .policy import DefaultKeywordResolver
19+
from .policy import SkillPolicy
20+
from .policy import TaxonomyPipeline
21+
from .policy import TaxonomyResolver
22+
from .taxonomy_config import TaxonomyRegistry
23+
from .taxonomy_config import TaxonomyTerm
24+
from .taxonomy_plugin import TaxonomyPlugin
25+
26+
__all__ = [
27+
"DefaultSkillPolicy",
28+
"DefaultKeywordResolver",
29+
"SkillPolicy",
30+
"TaxonomyPipeline",
31+
"TaxonomyPlugin",
32+
"TaxonomyRegistry",
33+
"TaxonomyResolver",
34+
"TaxonomyTerm",
35+
]
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Abstract interfaces for taxonomy resolution and skill policy enforcement."""
16+
17+
from __future__ import annotations
18+
19+
from abc import ABC, abstractmethod
20+
import logging
21+
from typing import Any, Optional
22+
23+
from google.adk.agents.readonly_context import ReadonlyContext
24+
from google.adk.models.llm_request import LlmRequest
25+
from google.adk.skills.models import Skill
26+
27+
logger = logging.getLogger("google_adk_community." + __name__)
28+
29+
class TaxonomyResolver(ABC):
30+
"""Abstract base class for taxonomy resolution.
31+
32+
Resolvers analyze context and LLM history to determine which taxonomy
33+
classification domains (e.g. URI strings) are currently active and relevant.
34+
"""
35+
36+
@abstractmethod
37+
async def resolve_taxonomies(
38+
self, context: ReadonlyContext, llm_request: LlmRequest
39+
) -> list[str]:
40+
"""Resolves active taxonomy domain URIs from context and LLM history.
41+
42+
Args:
43+
context: The current read-only execution context.
44+
llm_request: The upcoming LLM request holding prompt configurations.
45+
46+
Returns:
47+
A list of resolved active taxonomy strings/URIs.
48+
"""
49+
pass
50+
51+
52+
class TaxonomyPipeline(TaxonomyResolver):
53+
"""Executes a sequence of taxonomy resolvers in order (multi-step pipeline).
54+
55+
This implements a composite/pipeline pattern to merge active taxonomy domains
56+
identified by multiple independent heuristics (e.g. lexical, model-based).
57+
"""
58+
59+
def __init__(self, resolvers: list[TaxonomyResolver]):
60+
self.resolvers = resolvers
61+
62+
async def resolve_taxonomies(
63+
self, context: ReadonlyContext, llm_request: LlmRequest
64+
) -> list[str]:
65+
# Aggregates unique taxonomy domains across all registered resolvers
66+
active_domains: set[str] = set()
67+
for resolver in self.resolvers:
68+
domains = await resolver.resolve_taxonomies(context, llm_request)
69+
if domains:
70+
active_domains.update(domains)
71+
return list(active_domains)
72+
73+
74+
class DefaultKeywordResolver(TaxonomyResolver):
75+
"""Declarative, configuration-driven keyword/phrase resolver.
76+
77+
Scans user prompt history for triggering phrases defined directly inside each
78+
taxonomy term's triggers list or alt_labels, resolving active domains natively.
79+
"""
80+
81+
def __init__(self, registry: Any):
82+
self.registry = registry
83+
84+
async def resolve_taxonomies(self, context: ReadonlyContext, llm_request: LlmRequest) -> list[str]:
85+
active_domains: set[str] = set()
86+
87+
for term_id in self.registry.list_ids():
88+
term = self.registry.get_term(term_id)
89+
if term:
90+
triggers = getattr(term, "triggers", [])
91+
if not triggers and hasattr(term, "model_extra"):
92+
triggers = (term.model_extra or {}).get("triggers", [])
93+
94+
# Fall back to alt_labels as secondary keyword triggers
95+
if not triggers and hasattr(term, "alt_labels"):
96+
triggers = term.alt_labels
97+
98+
if triggers:
99+
for turn in llm_request.contents:
100+
for part in turn.parts:
101+
if part.text:
102+
text_upper = part.text.upper()
103+
if any(str(phrase).upper() in text_upper for phrase in triggers):
104+
active_domains.add(term_id)
105+
break
106+
107+
return list(active_domains)
108+
109+
110+
class SkillPolicy(ABC):
111+
"""Abstract policy engine determining skill execution permissions and instruction shaping.
112+
113+
This class defines the interface for two main responsibilities:
114+
1. Access Control (Authorization): Blocking or permitting skills based on active taxonomies.
115+
2. Cognitive Steering (Behavioral Shaping): Altering skill instructions, descriptions,
116+
prioritization, and global system prompts to steer agent execution dynamically.
117+
118+
Implements the Hook Method pattern, providing concrete default pass-throughs
119+
for steering while keeping authorization and core shaping abstract.
120+
"""
121+
122+
registry: Optional[Any] = None
123+
124+
@abstractmethod
125+
def is_skill_allowed(
126+
self,
127+
skill: Skill,
128+
context: ReadonlyContext,
129+
active_taxonomies: list[str],
130+
) -> bool:
131+
"""Determines if a skill can be loaded/used under the active taxonomies and context.
132+
133+
Args:
134+
skill: The target Skill model instance.
135+
context: The read-only interaction context.
136+
active_taxonomies: The list of currently active taxonomy domains.
137+
138+
Returns:
139+
True if the skill is permitted to run, False otherwise.
140+
"""
141+
pass
142+
143+
@abstractmethod
144+
def shape_instructions(
145+
self,
146+
skill: Skill,
147+
context: ReadonlyContext,
148+
original_instructions: str,
149+
) -> str:
150+
"""Applies dynamic instruction shaping/guardrails to a skill's instructions.
151+
152+
Use this to append safety restrictions, enforce compliance constraints,
153+
or adjust operating parameters of a skill before execution.
154+
"""
155+
pass
156+
157+
def shape_description(
158+
self,
159+
skill: Skill,
160+
context: ReadonlyContext,
161+
original_description: str,
162+
) -> str:
163+
"""Applies dynamic description shaping before the tool reaches the agent.
164+
165+
This can be used to emphasize specific features of a skill to the LLM or
166+
prune redundant information to fit within context limits.
167+
"""
168+
return original_description
169+
170+
def shape_system_instruction(
171+
self,
172+
context: ReadonlyContext,
173+
active_taxonomies: list[str],
174+
original_instructions: str,
175+
) -> str:
176+
"""Applies dynamic instruction shaping to the global agent system instructions.
177+
178+
Use this to dynamically inject directives (e.g. telling the LLM to trigger
179+
certain tools almost by default or prioritize specific workflows) depending
180+
on the current active taxonomy classification.
181+
"""
182+
return original_instructions
183+
184+
def prioritize_skills(
185+
self,
186+
skills: list[Skill],
187+
context: ReadonlyContext,
188+
active_taxonomies: list[str],
189+
) -> list[Skill]:
190+
"""Prioritizes, reorders, or accentuates skills under the active taxonomy.
191+
192+
Allows the policy to sort key tools to the top of the available_skills XML list
193+
presented in the prompt, encouraging the LLM to select preferred actions.
194+
"""
195+
return skills
196+
197+
def shape_skill(
198+
self,
199+
skill: Skill,
200+
context: ReadonlyContext,
201+
shaped_description: Optional[str],
202+
) -> Skill:
203+
"""Prepares and shapes a skill representation for presentation to the agent.
204+
205+
Defaults to a secure manual reconstruction to prevent accidental leakage of
206+
internal developer/business flags to LLM prompts, but can be overridden by
207+
custom policies to use `model_copy()` or other strategies.
208+
"""
209+
assert skill is not None, "Skill instance cannot be None"
210+
211+
from google.adk.skills.models import Skill, Frontmatter
212+
extra = getattr(skill.frontmatter, "model_extra", None) or {}
213+
return Skill(
214+
frontmatter=Frontmatter(
215+
name=skill.frontmatter.name,
216+
description=shaped_description,
217+
**extra
218+
),
219+
instructions=skill.instructions
220+
)
221+
222+
223+
def _get_taxonomy_binds(skill: Skill) -> list[str]:
224+
"""Dynamically extracts taxonomy binds, supporting both modified and unmodified core SDKs.
225+
226+
This utility functions as a robust protocol layer. If the SDK natively supports
227+
frontmatter taxonomy binds, it reads them directly. Otherwise, it falls back to parsing
228+
Pydantic extra fields (since core SDK uses `extra="allow"`), handling variations in
229+
hyphenation/naming conventions.
230+
"""
231+
# Direct attribute access check
232+
if hasattr(skill.frontmatter, "taxonomy_binds"):
233+
return skill.frontmatter.taxonomy_binds
234+
235+
# Fallback: Read from Pydantic's model_extra dictionary (natively populated because of extra="allow")
236+
extra = getattr(skill.frontmatter, "model_extra", None) or {}
237+
binds = extra.get("taxonomy-binds") or extra.get("taxonomy_binds") or []
238+
if isinstance(binds, str):
239+
return [binds]
240+
return list(binds)
241+
242+
243+
def _interpolate_variables(text: str, active_taxonomies: list[str], registry: Optional[Any]) -> str:
244+
if not text or not registry:
245+
return text
246+
247+
import re
248+
pattern = r"\{taxonomy:([a-zA-Z0-9_-]+)\}"
249+
250+
def replace(match):
251+
var_name = match.group(1)
252+
for tax_id in active_taxonomies:
253+
term = registry.get_term(tax_id)
254+
if term:
255+
variables = getattr(term, "variables", {})
256+
if not variables and hasattr(term, "model_extra"):
257+
variables = (term.model_extra or {}).get("variables", {})
258+
if variables and var_name in variables:
259+
return str(variables[var_name])
260+
261+
logger.warning("Taxonomy variable %r not found under active taxonomies: %s", var_name, active_taxonomies)
262+
return ""
263+
264+
return re.sub(pattern, replace, text)
265+
266+
267+
class DefaultSkillPolicy(SkillPolicy):
268+
"""Default skill policy using taxonomy-bind set-intersection matching.
269+
270+
If a skill has no taxonomy binds defined, it is treated as unrestricted/allowed by default.
271+
If it has binds, at least one bind must intersect with the active taxonomy set.
272+
"""
273+
274+
def __init__(self, registry: Optional[Any] = None):
275+
self.registry = registry
276+
277+
def is_skill_allowed(
278+
self,
279+
skill: Skill,
280+
context: ReadonlyContext,
281+
active_taxonomies: list[str],
282+
) -> bool:
283+
binds = _get_taxonomy_binds(skill)
284+
# Unrestricted skills are always allowed
285+
if not binds:
286+
return True
287+
# Require at least one matching taxonomy between active set and skill binds
288+
return bool(set(binds) & set(active_taxonomies))
289+
290+
def shape_instructions(
291+
self,
292+
skill: Skill,
293+
context: ReadonlyContext,
294+
original_instructions: str,
295+
) -> str:
296+
active_taxonomies = context.state.get("_active_taxonomies") or []
297+
return _interpolate_variables(original_instructions, active_taxonomies, self.registry)
298+
299+
def shape_description(
300+
self,
301+
skill: Skill,
302+
context: ReadonlyContext,
303+
original_description: str,
304+
) -> str:
305+
active_taxonomies = context.state.get("_active_taxonomies") or []
306+
return _interpolate_variables(original_description, active_taxonomies, self.registry)
307+
308+
def shape_system_instruction(
309+
self,
310+
context: ReadonlyContext,
311+
active_taxonomies: list[str],
312+
original_instructions: str,
313+
) -> str:
314+
return _interpolate_variables(original_instructions, active_taxonomies, self.registry)
315+
316+
def prioritize_skills(
317+
self,
318+
skills: list[Skill],
319+
context: ReadonlyContext,
320+
active_taxonomies: list[str],
321+
) -> list[Skill]:
322+
# No-op pass-through for default behavior
323+
return skills

0 commit comments

Comments
 (0)