Merge branch 'main' into rewbs/tool-use-charge-to-subscription
This commit is contained in:
@@ -512,3 +512,73 @@ class TestGatewayProtection:
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is False
|
||||
|
||||
|
||||
class TestNormalizationBypass:
|
||||
"""Obfuscation techniques must not bypass dangerous command detection."""
|
||||
|
||||
def test_fullwidth_unicode_rm(self):
|
||||
"""Fullwidth Unicode 'rm -rf /' must be caught after NFKC normalization."""
|
||||
cmd = "\uff52\uff4d -\uff52\uff46 /" # rm -rf /
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True, f"Fullwidth 'rm -rf /' was not detected: {cmd!r}"
|
||||
|
||||
def test_fullwidth_unicode_dd(self):
|
||||
"""Fullwidth 'dd if=/dev/zero' must be caught."""
|
||||
cmd = "\uff44\uff44 if=/dev/zero of=/dev/sda"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True
|
||||
|
||||
def test_fullwidth_unicode_chmod(self):
|
||||
"""Fullwidth 'chmod 777' must be caught."""
|
||||
cmd = "\uff43\uff48\uff4d\uff4f\uff44 777 /tmp/test"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True
|
||||
|
||||
def test_ansi_csi_wrapped_rm(self):
|
||||
"""ANSI CSI color codes wrapping 'rm' must be stripped and caught."""
|
||||
cmd = "\x1b[31mrm\x1b[0m -rf /"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True, f"ANSI-wrapped 'rm -rf /' was not detected"
|
||||
|
||||
def test_ansi_osc_embedded_rm(self):
|
||||
"""ANSI OSC sequences embedded in command must be stripped."""
|
||||
cmd = "\x1b]0;title\x07rm -rf /"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True
|
||||
|
||||
def test_ansi_8bit_c1_wrapped_rm(self):
|
||||
"""8-bit C1 CSI (0x9b) wrapping 'rm' must be stripped and caught."""
|
||||
cmd = "\x9b31mrm\x9b0m -rf /"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True, "8-bit C1 CSI bypass was not caught"
|
||||
|
||||
def test_null_byte_in_rm(self):
|
||||
"""Null bytes injected into 'rm' must be stripped and caught."""
|
||||
cmd = "r\x00m -rf /"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True, f"Null-byte 'rm' was not detected: {cmd!r}"
|
||||
|
||||
def test_null_byte_in_dd(self):
|
||||
"""Null bytes in 'dd' must be stripped."""
|
||||
cmd = "d\x00d if=/dev/sda"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True
|
||||
|
||||
def test_mixed_fullwidth_and_ansi(self):
|
||||
"""Combined fullwidth + ANSI obfuscation must still be caught."""
|
||||
cmd = "\x1b[1m\uff52\uff4d\x1b[0m -rf /"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is True
|
||||
|
||||
def test_safe_command_after_normalization(self):
|
||||
"""Normal safe commands must not be flagged after normalization."""
|
||||
cmd = "ls -la /tmp"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is False
|
||||
|
||||
def test_fullwidth_safe_command_not_flagged(self):
|
||||
"""Fullwidth 'ls -la' is safe and must not be flagged."""
|
||||
cmd = "\uff4c\uff53 -\uff4c\uff41 /tmp"
|
||||
dangerous, key, desc = detect_dangerous_command(cmd)
|
||||
assert dangerous is False
|
||||
|
||||
|
||||
66
tests/tools/test_delegate_toolset_scope.py
Normal file
66
tests/tools/test_delegate_toolset_scope.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Tests for delegate_tool toolset scoping.
|
||||
|
||||
Verifies that subagents cannot gain tools that the parent does not have.
|
||||
The LLM controls the `toolsets` parameter — without intersection with the
|
||||
parent's enabled_toolsets, it can escalate privileges by requesting
|
||||
arbitrary toolsets.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tools.delegate_tool import _strip_blocked_tools
|
||||
|
||||
|
||||
class TestToolsetIntersection:
|
||||
"""Subagent toolsets must be a subset of parent's enabled_toolsets."""
|
||||
|
||||
def test_requested_toolsets_intersected_with_parent(self):
|
||||
"""LLM requests toolsets parent doesn't have — extras are dropped."""
|
||||
parent = SimpleNamespace(enabled_toolsets=["terminal", "file"])
|
||||
|
||||
# Simulate the intersection logic from _build_child_agent
|
||||
parent_toolsets = set(parent.enabled_toolsets)
|
||||
requested = ["terminal", "file", "web", "browser", "rl"]
|
||||
scoped = [t for t in requested if t in parent_toolsets]
|
||||
|
||||
assert sorted(scoped) == ["file", "terminal"]
|
||||
assert "web" not in scoped
|
||||
assert "browser" not in scoped
|
||||
assert "rl" not in scoped
|
||||
|
||||
def test_all_requested_toolsets_available_on_parent(self):
|
||||
"""LLM requests subset of parent tools — all pass through."""
|
||||
parent = SimpleNamespace(enabled_toolsets=["terminal", "file", "web", "browser"])
|
||||
|
||||
parent_toolsets = set(parent.enabled_toolsets)
|
||||
requested = ["terminal", "web"]
|
||||
scoped = [t for t in requested if t in parent_toolsets]
|
||||
|
||||
assert sorted(scoped) == ["terminal", "web"]
|
||||
|
||||
def test_no_toolsets_requested_inherits_parent(self):
|
||||
"""When toolsets is None/empty, child inherits parent's set."""
|
||||
parent_toolsets = ["terminal", "file", "web"]
|
||||
child = _strip_blocked_tools(parent_toolsets)
|
||||
assert "terminal" in child
|
||||
assert "file" in child
|
||||
assert "web" in child
|
||||
|
||||
def test_strip_blocked_removes_delegation(self):
|
||||
"""Blocked toolsets (delegation, clarify, etc.) are always removed."""
|
||||
child = _strip_blocked_tools(["terminal", "delegation", "clarify", "memory"])
|
||||
assert "delegation" not in child
|
||||
assert "clarify" not in child
|
||||
assert "memory" not in child
|
||||
assert "terminal" in child
|
||||
|
||||
def test_empty_intersection_yields_empty_toolsets(self):
|
||||
"""If parent has no overlap with requested, child gets nothing extra."""
|
||||
parent = SimpleNamespace(enabled_toolsets=["terminal"])
|
||||
|
||||
parent_toolsets = set(parent.enabled_toolsets)
|
||||
requested = ["web", "browser"]
|
||||
scoped = [t for t in requested if t in parent_toolsets]
|
||||
|
||||
assert scoped == []
|
||||
@@ -8,6 +8,7 @@ from tools.session_search_tool import (
|
||||
_format_timestamp,
|
||||
_format_conversation,
|
||||
_truncate_around_matches,
|
||||
_HIDDEN_SESSION_SOURCES,
|
||||
MAX_SESSION_CHARS,
|
||||
SESSION_SEARCH_SCHEMA,
|
||||
)
|
||||
@@ -17,6 +18,17 @@ from tools.session_search_tool import (
|
||||
# Tool schema guidance
|
||||
# =========================================================================
|
||||
|
||||
class TestHiddenSessionSources:
|
||||
"""Verify the _HIDDEN_SESSION_SOURCES constant used for third-party isolation."""
|
||||
|
||||
def test_tool_source_is_hidden(self):
|
||||
assert "tool" in _HIDDEN_SESSION_SOURCES
|
||||
|
||||
def test_standard_sources_not_hidden(self):
|
||||
for src in ("cli", "telegram", "discord", "slack", "cron"):
|
||||
assert src not in _HIDDEN_SESSION_SOURCES
|
||||
|
||||
|
||||
class TestSessionSearchSchema:
|
||||
def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self):
|
||||
description = SESSION_SEARCH_SCHEMA["description"]
|
||||
|
||||
@@ -55,6 +55,13 @@ class TestResolveTrustLevel:
|
||||
assert _resolve_trust_level("anthropics/skills") == "trusted"
|
||||
assert _resolve_trust_level("openai/skills/some-skill") == "trusted"
|
||||
|
||||
def test_skills_sh_wrapped_trusted_repos(self):
|
||||
assert _resolve_trust_level("skills-sh/openai/skills/skill-creator") == "trusted"
|
||||
assert _resolve_trust_level("skills-sh/anthropics/skills/frontend-design") == "trusted"
|
||||
|
||||
def test_common_skills_sh_prefix_typo_still_maps_to_trusted_repo(self):
|
||||
assert _resolve_trust_level("skils-sh/anthropics/skills/frontend-design") == "trusted"
|
||||
|
||||
def test_community_default(self):
|
||||
assert _resolve_trust_level("random-user/my-skill") == "community"
|
||||
assert _resolve_trust_level("") == "community"
|
||||
|
||||
@@ -179,6 +179,24 @@ class TestSkillsShSource:
|
||||
assert bundle.identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices"
|
||||
mock_fetch.assert_called_once_with("vercel-labs/agent-skills/vercel-react-best-practices")
|
||||
|
||||
@patch.object(GitHubSource, "fetch")
|
||||
def test_fetch_accepts_common_skills_sh_prefix_typo(self, mock_fetch):
|
||||
expected_identifier = "anthropics/skills/frontend-design"
|
||||
mock_fetch.side_effect = lambda identifier: SkillBundle(
|
||||
name="frontend-design",
|
||||
files={"SKILL.md": "# Frontend Design"},
|
||||
source="github",
|
||||
identifier=expected_identifier,
|
||||
trust_level="trusted",
|
||||
) if identifier == expected_identifier else None
|
||||
|
||||
bundle = self._source().fetch("skils-sh/anthropics/skills/frontend-design")
|
||||
|
||||
assert bundle is not None
|
||||
assert bundle.source == "skills.sh"
|
||||
assert bundle.identifier == "skills-sh/anthropics/skills/frontend-design"
|
||||
assert mock_fetch.call_args_list[0] == ((expected_identifier,), {})
|
||||
|
||||
@patch("tools.skills_hub._write_index_cache")
|
||||
@patch("tools.skills_hub._read_index_cache", return_value=None)
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
@@ -213,6 +231,26 @@ class TestSkillsShSource:
|
||||
assert meta.extra["security_audits"]["socket"] == "Pass"
|
||||
mock_inspect.assert_called_once_with("vercel-labs/agent-skills/vercel-react-best-practices")
|
||||
|
||||
@patch.object(GitHubSource, "inspect")
|
||||
def test_inspect_accepts_common_skills_sh_prefix_typo(self, mock_inspect):
|
||||
expected_identifier = "anthropics/skills/frontend-design"
|
||||
mock_inspect.side_effect = lambda identifier: SkillMeta(
|
||||
name="frontend-design",
|
||||
description="Distinctive frontend interfaces.",
|
||||
source="github",
|
||||
identifier=expected_identifier,
|
||||
trust_level="trusted",
|
||||
repo="anthropics/skills",
|
||||
path="frontend-design",
|
||||
) if identifier == expected_identifier else None
|
||||
|
||||
meta = self._source().inspect("skils-sh/anthropics/skills/frontend-design")
|
||||
|
||||
assert meta is not None
|
||||
assert meta.source == "skills.sh"
|
||||
assert meta.identifier == "skills-sh/anthropics/skills/frontend-design"
|
||||
assert mock_inspect.call_args_list[0] == ((expected_identifier,), {})
|
||||
|
||||
@patch.object(GitHubSource, "_list_skills_in_repo")
|
||||
@patch.object(GitHubSource, "inspect")
|
||||
def test_inspect_falls_back_to_repo_skill_catalog_when_slug_differs(self, mock_inspect, mock_list_skills):
|
||||
@@ -307,6 +345,39 @@ class TestSkillsShSource:
|
||||
assert bundle.files["SKILL.md"] == "# react"
|
||||
assert mock_get.called
|
||||
|
||||
@patch("tools.skills_hub._write_index_cache")
|
||||
@patch("tools.skills_hub._read_index_cache", return_value=None)
|
||||
@patch.object(SkillsShSource, "_discover_identifier")
|
||||
@patch.object(SkillsShSource, "_fetch_detail_page")
|
||||
@patch.object(GitHubSource, "fetch")
|
||||
def test_fetch_downloads_only_the_resolved_identifier(
|
||||
self,
|
||||
mock_fetch,
|
||||
mock_detail,
|
||||
mock_discover,
|
||||
_mock_read_cache,
|
||||
_mock_write_cache,
|
||||
):
|
||||
resolved_identifier = "owner/repo/product-team/product-designer"
|
||||
mock_detail.return_value = {"repo": "owner/repo", "install_skill": "product-designer"}
|
||||
mock_discover.return_value = resolved_identifier
|
||||
resolved_bundle = SkillBundle(
|
||||
name="product-designer",
|
||||
files={"SKILL.md": "# Product Designer"},
|
||||
source="github",
|
||||
identifier=resolved_identifier,
|
||||
trust_level="community",
|
||||
)
|
||||
mock_fetch.side_effect = lambda identifier: resolved_bundle if identifier == resolved_identifier else None
|
||||
|
||||
bundle = self._source().fetch("skills-sh/owner/repo/product-designer")
|
||||
|
||||
assert bundle is not None
|
||||
assert bundle.identifier == "skills-sh/owner/repo/product-designer"
|
||||
# All candidate identifiers are tried before falling back to discovery
|
||||
assert mock_fetch.call_args_list[-1] == ((resolved_identifier,), {})
|
||||
assert mock_fetch.call_args_list[0] == (("owner/repo/product-designer",), {})
|
||||
|
||||
@patch("tools.skills_hub._write_index_cache")
|
||||
@patch("tools.skills_hub._read_index_cache", return_value=None)
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
@@ -369,6 +440,36 @@ class TestSkillsShSource:
|
||||
# Verify the tree-resolved identifier was used for the final GitHub fetch
|
||||
mock_fetch.assert_any_call("owner/repo/cli-tool/components/skills/development/my-skill")
|
||||
|
||||
@patch.object(GitHubSource, "_find_skill_in_repo_tree")
|
||||
@patch.object(GitHubSource, "_list_skills_in_repo")
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_discover_identifier_uses_tree_search_before_root_scan(
|
||||
self,
|
||||
mock_get,
|
||||
mock_list_skills,
|
||||
mock_find_in_tree,
|
||||
):
|
||||
root_url = "https://api.github.com/repos/owner/repo/contents/"
|
||||
mock_list_skills.return_value = []
|
||||
mock_find_in_tree.return_value = "owner/repo/product-team/product-designer"
|
||||
|
||||
def _httpx_get_side_effect(url, **kwargs):
|
||||
resp = MagicMock()
|
||||
if url == root_url:
|
||||
resp.status_code = 200
|
||||
resp.json = lambda: []
|
||||
return resp
|
||||
resp.status_code = 404
|
||||
return resp
|
||||
|
||||
mock_get.side_effect = _httpx_get_side_effect
|
||||
|
||||
result = self._source()._discover_identifier("owner/repo/product-designer")
|
||||
|
||||
assert result == "owner/repo/product-team/product-designer"
|
||||
requested_urls = [call.args[0] for call in mock_get.call_args_list]
|
||||
assert root_url not in requested_urls
|
||||
|
||||
|
||||
class TestFindSkillInRepoTree:
|
||||
"""Tests for GitHubSource._find_skill_in_repo_tree."""
|
||||
|
||||
Reference in New Issue
Block a user