Skip to content

Commit 4ae2bfb

Browse files
authored
Merge pull request #1158 from Rhosmarie/feat/skill-space-black
feat(harness): support per-space skill policies
2 parents 31d2c67 + eab2cc8 commit 4ae2bfb

9 files changed

Lines changed: 639 additions & 57 deletions

File tree

‎tests/cloud/test_harness_app_contract.py‎

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
HarnessResourceOverride,
5454
HarnessResponseMetrics,
5555
HarnessSelectedSkill,
56+
HarnessSkillSpacePolicy,
5657
InvokeHarnessRequest,
5758
InvokeHarnessResponse,
5859
LlmUsageMetrics,
@@ -207,6 +208,50 @@ def test_structured_tools_skills_and_mcp_are_accepted(self):
207208
)
208209
assert h.mcp == [HarnessMcpServer(name="db", server_url="http://db.test/mcp")]
209210

211+
def test_selected_skill_accepts_skill_space_policy(self):
212+
h = HarnessOverrides.model_validate(
213+
{
214+
"selected_skills": [
215+
{
216+
"source": "skillspace",
217+
"skill_space_id": "ss-a",
218+
"skill_space_policy": {
219+
"mode": "deny",
220+
"ids": ["s-blocked"],
221+
},
222+
}
223+
]
224+
}
225+
)
226+
227+
assert h.selected_skills == [
228+
HarnessSelectedSkill(
229+
source="skillspace",
230+
skill_space_id="ss-a",
231+
skill_space_policy=HarnessSkillSpacePolicy(
232+
mode="deny",
233+
ids=["s-blocked"],
234+
),
235+
)
236+
]
237+
238+
def test_selected_skill_policy_requires_skillspace_source(self):
239+
with pytest.raises(ValidationError, match="only supported for skillspace"):
240+
HarnessOverrides.model_validate(
241+
{
242+
"selected_skills": [
243+
{
244+
"source": "skillhub",
245+
"slug": "team/reporting",
246+
"skill_space_policy": {
247+
"mode": "allow",
248+
"ids": ["s-1"],
249+
},
250+
}
251+
]
252+
}
253+
)
254+
210255
def test_structured_registry_is_accepted_and_normalized(self):
211256
h = HarnessOverrides.model_validate(
212257
{
@@ -1236,7 +1281,9 @@ def __init__(self, names):
12361281
def custom_tool():
12371282
return "custom"
12381283

1239-
def fake_build_skill_toolset(skill_ids, download_dir=None):
1284+
def fake_build_skill_toolset(
1285+
skill_ids, download_dir=None, skill_space_policy=None
1286+
):
12401287
return FakeSkillToolset(skill_ids) if skill_ids else None
12411288

12421289
custom_tool.__name__ = "custom_tool"
@@ -1260,6 +1307,95 @@ def fake_build_skill_toolset(skill_ids, download_dir=None):
12601307
assert len(skill_toolsets) == 1
12611308
assert skill_toolsets[0].names == ["team/new-skill"]
12621309

1310+
def test_spawn_passes_selected_skill_space_policy(self, monkeypatch):
1311+
from veadk.cloud.harness_app import utils
1312+
1313+
captured = {}
1314+
1315+
class FakeSkillToolset:
1316+
def __init__(self, names):
1317+
self.names = names
1318+
1319+
def fake_build_skill_toolset(
1320+
skill_ids, download_dir=None, skill_space_policy=None
1321+
):
1322+
captured["skill_ids"] = list(skill_ids)
1323+
captured["policy"] = (
1324+
skill_space_policy.to_json() if skill_space_policy else None
1325+
)
1326+
return FakeSkillToolset(skill_ids) if skill_ids else None
1327+
1328+
monkeypatch.delenv("SKILL_SPACE_POLICY", raising=False)
1329+
monkeypatch.setattr(utils, "SkillToolset", FakeSkillToolset)
1330+
monkeypatch.setattr(utils, "build_skill_toolset", fake_build_skill_toolset)
1331+
base = Agent(model_name="base-model", model_api_key="test-key")
1332+
1333+
spawn_harness_agent(
1334+
base,
1335+
HarnessOverrides(
1336+
selected_skills=[
1337+
{
1338+
"source": "skillspace",
1339+
"skill_space_id": "ss-a",
1340+
"skill_space_policy": {
1341+
"mode": "allow",
1342+
"ids": ["s-a2", "s-a1", "s-a2"],
1343+
},
1344+
},
1345+
{
1346+
"source": "skillspace",
1347+
"skill_space_id": "ss-b",
1348+
"skill_space_policy": {
1349+
"mode": "deny",
1350+
"ids": ["s-b9"],
1351+
},
1352+
},
1353+
]
1354+
),
1355+
)
1356+
1357+
assert captured["skill_ids"] == ["space:ss-a", "space:ss-b"]
1358+
assert captured["policy"] == (
1359+
'{"spaces":{"ss-a":{"mode":"allow","ids":["s-a1","s-a2"]},'
1360+
'"ss-b":{"mode":"deny","ids":["s-b9"]}}}'
1361+
)
1362+
assert "SKILL_SPACE_POLICY" not in os.environ
1363+
1364+
def test_spawn_rejects_conflicting_selected_skill_space_policy(self, monkeypatch):
1365+
from veadk.cloud.harness_app import utils
1366+
1367+
monkeypatch.setattr(
1368+
utils, "build_skill_toolset", lambda *_args, **_kwargs: None
1369+
)
1370+
base = Agent(model_name="base-model", model_api_key="test-key")
1371+
1372+
with pytest.raises(ValueError, match="Conflicting skill_space_policy"):
1373+
spawn_harness_agent(
1374+
base,
1375+
HarnessOverrides(
1376+
selected_skills=[
1377+
{
1378+
"source": "skillspace",
1379+
"skill_space_id": "ss-a",
1380+
"skill_id": "s-a1",
1381+
"skill_space_policy": {
1382+
"mode": "allow",
1383+
"ids": ["s-a1"],
1384+
},
1385+
},
1386+
{
1387+
"source": "skillspace",
1388+
"skill_space_id": "ss-a",
1389+
"skill_id": "s-a2",
1390+
"skill_space_policy": {
1391+
"mode": "deny",
1392+
"ids": ["s-a9"],
1393+
},
1394+
},
1395+
]
1396+
),
1397+
)
1398+
12631399
def test_spawn_clears_builtin_tools_and_skills_with_empty_overrides(
12641400
self, monkeypatch
12651401
):

‎tests/cloud/test_harness_skill_download.py‎

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from google.adk.tools.skill_toolset import SkillToolset
3232

3333
from veadk.cloud.harness_app import utils
34+
from veadk.skills.policy import parse_skill_space_policy
3435
from veadk.skills.skill import Skill as VeADKSkill
3536

3637

@@ -152,6 +153,159 @@ def fail_skillhub_download(*args, **kwargs):
152153
assert isinstance(toolset._code_executor, UnsafeLocalCodeExecutor)
153154

154155

156+
@pytest.mark.parametrize(
157+
("policy", "expected_names"),
158+
[
159+
('{"mode":"allow","ids":["s-allow"]}', ["allowed-skill"]),
160+
('{"mode":"deny","ids":["s-deny"]}', ["allowed-skill"]),
161+
],
162+
)
163+
def test_build_skill_toolset_applies_skill_space_policy(
164+
monkeypatch,
165+
tmp_path,
166+
policy,
167+
expected_names,
168+
):
169+
remote_skills = [
170+
VeADKSkill(
171+
name="allowed-skill",
172+
description="Allowed skill.",
173+
path="skills/s-allow/v1/allowed-skill.zip",
174+
skill_space_id="ss-test",
175+
bucket_name="bucket",
176+
id="s-allow",
177+
),
178+
VeADKSkill(
179+
name="denied-skill",
180+
description="Denied skill.",
181+
path="skills/s-deny/v1/denied-skill.zip",
182+
skill_space_id="ss-test",
183+
bucket_name="bucket",
184+
id="s-deny",
185+
),
186+
]
187+
materialized: list[str] = []
188+
189+
monkeypatch.setenv("SKILL_SPACE_POLICY", policy)
190+
monkeypatch.setattr(
191+
utils,
192+
"_load_skills_from_space_id",
193+
lambda _skill_space_id: remote_skills,
194+
)
195+
196+
def fake_materialize_remote_skill(
197+
skill: VeADKSkill,
198+
*,
199+
cache_dir=None,
200+
):
201+
materialized.append(skill.name)
202+
skill_dir = tmp_path / skill.name
203+
_write_adk_skill(skill_dir, name=skill.name)
204+
return skill_dir
205+
206+
monkeypatch.setattr(
207+
utils,
208+
"materialize_remote_skill",
209+
fake_materialize_remote_skill,
210+
)
211+
212+
toolset = utils.build_skill_toolset(["space:ss-test"], download_dir=tmp_path)
213+
214+
assert materialized == expected_names
215+
assert [skill.name for skill in toolset._list_skills()] == expected_names
216+
217+
218+
def test_build_skill_toolset_allows_empty_skill_space_policy(
219+
monkeypatch,
220+
tmp_path,
221+
):
222+
remote_skills = [
223+
VeADKSkill(
224+
name="remote-skill",
225+
description="Remote skill.",
226+
path="skills/s-1/v1/remote-skill.zip",
227+
skill_space_id="ss-test",
228+
bucket_name="bucket",
229+
id="s-1",
230+
)
231+
]
232+
233+
monkeypatch.setenv("SKILL_SPACE_POLICY", '{"mode":"allow","ids":[]}')
234+
monkeypatch.setattr(
235+
utils,
236+
"_load_skills_from_space_id",
237+
lambda _skill_space_id: remote_skills,
238+
)
239+
monkeypatch.setattr(
240+
utils,
241+
"materialize_remote_skill",
242+
lambda *_args, **_kwargs: pytest.fail(
243+
"filtered skills must not be materialized"
244+
),
245+
)
246+
247+
assert utils.build_skill_toolset(["space:ss-test"], download_dir=tmp_path) is None
248+
249+
250+
def test_build_skill_toolset_prefers_explicit_policy_over_env(
251+
monkeypatch,
252+
tmp_path,
253+
):
254+
remote_skills = [
255+
VeADKSkill(
256+
name="explicit-skill",
257+
description="Explicit skill.",
258+
path="skills/s-explicit/v1/explicit-skill.zip",
259+
skill_space_id="ss-test",
260+
bucket_name="bucket",
261+
id="s-explicit",
262+
),
263+
VeADKSkill(
264+
name="env-skill",
265+
description="Env skill.",
266+
path="skills/s-env/v1/env-skill.zip",
267+
skill_space_id="ss-test",
268+
bucket_name="bucket",
269+
id="s-env",
270+
),
271+
]
272+
materialized: list[str] = []
273+
274+
monkeypatch.setenv("SKILL_SPACE_POLICY", '{"mode":"allow","ids":["s-env"]}')
275+
monkeypatch.setattr(
276+
utils,
277+
"_load_skills_from_space_id",
278+
lambda _skill_space_id: remote_skills,
279+
)
280+
281+
def fake_materialize_remote_skill(
282+
skill: VeADKSkill,
283+
*,
284+
cache_dir=None,
285+
):
286+
materialized.append(skill.name)
287+
skill_dir = tmp_path / skill.name
288+
_write_adk_skill(skill_dir, name=skill.name)
289+
return skill_dir
290+
291+
monkeypatch.setattr(
292+
utils,
293+
"materialize_remote_skill",
294+
fake_materialize_remote_skill,
295+
)
296+
297+
toolset = utils.build_skill_toolset(
298+
["space:ss-test"],
299+
download_dir=tmp_path,
300+
skill_space_policy=parse_skill_space_policy(
301+
'{"mode":"allow","ids":["s-explicit"]}'
302+
),
303+
)
304+
305+
assert materialized == ["explicit-skill"]
306+
assert [skill.name for skill in toolset._list_skills()] == ["explicit-skill"]
307+
308+
155309
def test_replace_skills_replaces_existing_skill_toolset(monkeypatch, tmp_path):
156310
existing_dir = tmp_path / "existing"
157311
incoming_dir = tmp_path / "incoming"
@@ -170,7 +324,7 @@ def test_replace_skills_replaces_existing_skill_toolset(monkeypatch, tmp_path):
170324
agent = SimpleNamespace(tools=[marker_tool, existing_toolset])
171325
calls: list[tuple[list[str], object]] = []
172326

173-
def fake_build_skill_toolset(skill_ids, download_dir=None):
327+
def fake_build_skill_toolset(skill_ids, download_dir=None, skill_space_policy=None):
174328
calls.append((skill_ids, download_dir))
175329
return incoming_toolset
176330

‎tests/frontend/server/skills/test_reviews.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,16 @@
4242
def package(name: str, text: str) -> bytes:
4343
data = io.BytesIO()
4444
with zipfile.ZipFile(data, "w") as archive:
45-
archive.writestr(
46-
f"{name}/SKILL.md",
47-
f"---\nname: {name}\ndescription: Review test\n---\n{text}",
48-
)
49-
archive.writestr(f"{name}/references/data.txt", text)
45+
for path, content in [
46+
(
47+
f"{name}/SKILL.md",
48+
f"---\nname: {name}\ndescription: Review test\n---\n{text}",
49+
),
50+
(f"{name}/references/data.txt", text),
51+
]:
52+
info = zipfile.ZipInfo(path)
53+
info.date_time = (2025, 1, 1, 0, 0, 0)
54+
archive.writestr(info, content)
5055
return data.getvalue()
5156

5257

0 commit comments

Comments
 (0)