Skip to content

Commit 917ebe3

Browse files
authored
feat(skills): support skill space allow deny policy (#1136)
1 parent 6303f37 commit 917ebe3

7 files changed

Lines changed: 437 additions & 7 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
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+
import asyncio
16+
import json
17+
18+
import pytest
19+
20+
from veadk.skills import utils
21+
from veadk.skills import registry as registry_module
22+
from veadk.skills.policy import (
23+
MAX_SKILL_SPACE_POLICY_BYTES,
24+
SkillSpacePolicyError,
25+
parse_skill_space_policy,
26+
)
27+
from veadk.skills.skill import Skill
28+
from veadk.skills.registry import VeSkillRegistry
29+
30+
31+
def _skill(skill_id: str | None, name: str) -> Skill:
32+
return Skill(
33+
id=skill_id,
34+
name=name,
35+
description=f"{name} description",
36+
path=f"skills/{name}.zip",
37+
skill_space_id="ss-test",
38+
)
39+
40+
41+
@pytest.mark.parametrize(
42+
("raw_value", "message"),
43+
[
44+
('{"mode":"other","ids":[]}', "mode"),
45+
('{"mode":"allow","ids":"skill-1"}', "ids must be a list"),
46+
('{"mode":"allow","ids":[""]}', "non-empty strings"),
47+
('{"mode":"allow","ids":[],"v":1}', "exactly 'mode' and 'ids'"),
48+
('{"ref":"skill-policy-1"}', "exactly 'mode' and 'ids'"),
49+
],
50+
)
51+
def test_parse_skill_space_policy_rejects_unsupported_shapes(raw_value, message):
52+
with pytest.raises(SkillSpacePolicyError, match=message):
53+
parse_skill_space_policy(raw_value)
54+
55+
56+
def test_parse_skill_space_policy_rejects_values_over_create_session_limit():
57+
raw_value = "x" * (MAX_SKILL_SPACE_POLICY_BYTES + 1)
58+
59+
with pytest.raises(SkillSpacePolicyError, match="must not exceed 8192 bytes"):
60+
parse_skill_space_policy(raw_value)
61+
62+
63+
def test_policy_json_is_compact_and_deduplicated():
64+
policy = parse_skill_space_policy(
65+
'{"mode": "deny", "ids": ["skill-2", "skill-1", "skill-2"]}'
66+
)
67+
68+
assert policy.to_json() == '{"mode":"deny","ids":["skill-1","skill-2"]}'
69+
70+
71+
@pytest.mark.parametrize(
72+
("mode", "ids", "expected"),
73+
[
74+
("deny", ["skill-2"], ["skill-1"]),
75+
("allow", ["skill-2"], ["skill-2"]),
76+
("deny", [], ["skill-1", "skill-2"]),
77+
("allow", [], []),
78+
],
79+
)
80+
def test_load_skills_from_cloud_applies_policy(
81+
monkeypatch: pytest.MonkeyPatch,
82+
mode: str,
83+
ids: list[str],
84+
expected: list[str],
85+
):
86+
monkeypatch.setenv(
87+
"SKILL_SPACE_POLICY",
88+
json.dumps({"mode": mode, "ids": ids}),
89+
)
90+
monkeypatch.setattr(
91+
utils,
92+
"_load_skills_from_space_id",
93+
lambda _space_id, *, raise_on_error=False: [
94+
_skill("skill-1", "one"),
95+
_skill("skill-2", "two"),
96+
],
97+
)
98+
99+
skills = utils.load_skills_from_cloud("ss-test")
100+
101+
assert [skill.id for skill in skills] == expected
102+
103+
104+
def test_missing_policy_keeps_all_remote_skills(monkeypatch: pytest.MonkeyPatch):
105+
monkeypatch.delenv("SKILL_SPACE_POLICY", raising=False)
106+
monkeypatch.setattr(
107+
utils,
108+
"_load_skills_from_space_id",
109+
lambda _space_id, *, raise_on_error=False: [
110+
_skill("skill-1", "one"),
111+
_skill("skill-2", "two"),
112+
],
113+
)
114+
115+
skills = utils.load_skills_from_cloud("ss-test")
116+
117+
assert [skill.id for skill in skills] == ["skill-1", "skill-2"]
118+
119+
120+
def test_policy_excludes_remote_skills_without_stable_ids(
121+
monkeypatch: pytest.MonkeyPatch,
122+
):
123+
monkeypatch.setenv(
124+
"SKILL_SPACE_POLICY",
125+
'{"mode":"deny","ids":[]}',
126+
)
127+
monkeypatch.setattr(
128+
utils,
129+
"_load_skills_from_space_id",
130+
lambda _space_id, *, raise_on_error=False: [_skill(None, "missing-id")],
131+
)
132+
133+
assert utils.load_skills_from_cloud("ss-test") == []
134+
135+
136+
def test_invalid_policy_disables_remote_skills_without_listing_space(
137+
monkeypatch: pytest.MonkeyPatch,
138+
):
139+
monkeypatch.setenv("SKILL_SPACE_POLICY", '{"mode":"allow","ids":[],"v":1}')
140+
monkeypatch.setattr(
141+
utils,
142+
"_load_skills_from_space_id",
143+
lambda *_args, **_kwargs: pytest.fail("invalid policy must fail closed"),
144+
)
145+
146+
assert utils.load_skills_from_cloud("ss-test") == []
147+
148+
149+
def test_registry_get_skill_cannot_bypass_deny_policy(
150+
monkeypatch: pytest.MonkeyPatch,
151+
):
152+
monkeypatch.setenv(
153+
"SKILL_SPACE_POLICY",
154+
'{"mode":"deny","ids":["skill-1"]}',
155+
)
156+
monkeypatch.setattr(
157+
utils,
158+
"_load_skills_from_space_id",
159+
lambda _space_id, *, raise_on_error=False: [_skill("skill-1", "one")],
160+
)
161+
monkeypatch.setattr(
162+
registry_module,
163+
"materialize_remote_skill",
164+
lambda *_args, **_kwargs: pytest.fail("denied Skill must not be downloaded"),
165+
)
166+
167+
registry = VeSkillRegistry(skill_source_id="ss-test")
168+
169+
with pytest.raises(ValueError, match="not found"):
170+
asyncio.run(registry.get_skill(name="one"))

‎tests/tools/builtin_tools/test_agentkit.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,57 @@ def get_session(self, _request):
374374
},
375375
)
376376

377+
def test_create_session_injects_compact_skill_space_policy(self):
378+
captured = {}
379+
380+
class FakeClient:
381+
def list_sessions(self, _request):
382+
return types.SimpleNamespace(session_infos=[])
383+
384+
def create_session(self, request):
385+
captured["request"] = request
386+
return types.SimpleNamespace(session_id="session-1")
387+
388+
with patch.dict(
389+
os.environ,
390+
{
391+
"SKILL_SPACE_POLICY": (
392+
'{"mode": "deny", "ids": ["skill-2", "skill-1", "skill-2"]}'
393+
)
394+
},
395+
):
396+
self.agentkit_module._get_or_create_agentkit_session(
397+
client=FakeClient(),
398+
tool_id="tool-1",
399+
tool_user_session_id="user-session-1",
400+
ttl=900,
401+
)
402+
403+
request = captured["request"]
404+
assert len(request.envs) == 1
405+
assert request.envs[0].key == "SKILL_SPACE_POLICY"
406+
assert request.envs[0].value == '{"mode":"deny","ids":["skill-1","skill-2"]}'
407+
408+
def test_create_session_rejects_unsupported_skill_space_policy(self):
409+
class FakeClient:
410+
def list_sessions(self, _request):
411+
return types.SimpleNamespace(session_infos=[])
412+
413+
def create_session(self, _request):
414+
raise AssertionError("invalid policy must not reach CreateSession")
415+
416+
with patch.dict(
417+
os.environ,
418+
{"SKILL_SPACE_POLICY": '{"mode":"allow","ids":[],"ref":"x"}'},
419+
):
420+
with self.assertRaisesRegex(ValueError, "exactly 'mode' and 'ids'"):
421+
self.agentkit_module._get_or_create_agentkit_session(
422+
client=FakeClient(),
423+
tool_id="tool-1",
424+
tool_user_session_id="user-session-1",
425+
ttl=900,
426+
)
427+
377428
def test_uses_create_session_endpoint_without_waiting_by_default(self):
378429
captured = {"get_calls": 0}
379430

‎tests/tools/builtin_tools/test_run_sandbox_agent.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import importlib.util
1616
import hashlib
1717
import json
18+
import os
1819
import sys
1920
import types
2021
import unittest
@@ -223,6 +224,74 @@ def test_runner_code_overrides_the_sandbox_process_environment(self):
223224
self.assertNotIn("if key not in env", code)
224225
self.assertIn('srv_pythonpath = env.get("SRV_PYTHONPATH")', code)
225226

227+
def test_run_sandbox_agent_forwards_compact_skill_space_policy(self):
228+
invocation_context = types.SimpleNamespace(
229+
session=types.SimpleNamespace(id="session-1"),
230+
agent=types.SimpleNamespace(name="agent"),
231+
user_id="user",
232+
)
233+
tool_context = types.SimpleNamespace(
234+
_invocation_context=invocation_context,
235+
state={},
236+
)
237+
response = {"Result": {"Result": "done"}}
238+
239+
with (
240+
patch.dict(
241+
os.environ,
242+
{
243+
"SKILL_SPACE_ID": "ss-test",
244+
"SKILL_SPACE_POLICY": (
245+
'{"mode": "deny", "ids": ["skill-2", "skill-1", "skill-2"]}'
246+
),
247+
},
248+
),
249+
patch.object(
250+
self.module,
251+
"invoke_agentkit_run_code",
252+
return_value=response,
253+
) as invoke,
254+
):
255+
self.module.run_sandbox_agent(
256+
"do work",
257+
"tool-1",
258+
tool_context=tool_context,
259+
)
260+
261+
runner_code = invoke.call_args.kwargs["code"]
262+
self.assertIn("SKILL_SPACE_POLICY", runner_code)
263+
self.assertIn('{"mode":"deny","ids":["skill-1","skill-2"]}', runner_code)
264+
265+
def test_run_sandbox_agent_rejects_unsupported_skill_space_policy(self):
266+
invocation_context = types.SimpleNamespace(
267+
session=types.SimpleNamespace(id="session-1"),
268+
agent=types.SimpleNamespace(name="agent"),
269+
user_id="user",
270+
)
271+
tool_context = types.SimpleNamespace(
272+
_invocation_context=invocation_context,
273+
state={},
274+
)
275+
276+
with (
277+
patch.dict(
278+
os.environ,
279+
{"SKILL_SPACE_POLICY": '{"mode":"allow","ids":[],"ref":"x"}'},
280+
),
281+
patch.object(
282+
self.module,
283+
"invoke_agentkit_run_code",
284+
) as invoke,
285+
):
286+
with self.assertRaisesRegex(ValueError, "exactly 'mode' and 'ids'"):
287+
self.module.run_sandbox_agent(
288+
"do work",
289+
"tool-1",
290+
tool_context=tool_context,
291+
)
292+
293+
invoke.assert_not_called()
294+
226295

227296
class TestExecuteSkillsSkillApi(unittest.TestCase):
228297
def _tool_context(self, *, inbound_credential=None, credentials_by_key=None):

0 commit comments

Comments
 (0)