diff --git a/src/skillspector/nodes/analyzers/__init__.py b/src/skillspector/nodes/analyzers/__init__.py index 58b3e93..1824eec 100644 --- a/src/skillspector/nodes/analyzers/__init__.py +++ b/src/skillspector/nodes/analyzers/__init__.py @@ -33,6 +33,9 @@ from skillspector.nodes.analyzers.semantic_security_discovery import ( node as semantic_security_discovery_node, ) +from skillspector.nodes.analyzers.static_patterns_agent_snooping import ( + node as static_patterns_agent_snooping_node, +) from skillspector.nodes.analyzers.static_patterns_data_exfiltration import ( node as static_patterns_data_exfiltration_node, ) @@ -80,6 +83,7 @@ "static_patterns_memory_poisoning", "static_patterns_tool_misuse", "static_patterns_rogue_agent", + "static_patterns_agent_snooping", "static_yara", "behavioral_ast", "behavioral_taint_tracking", @@ -103,6 +107,7 @@ "static_patterns_memory_poisoning": static_patterns_memory_poisoning_node, "static_patterns_tool_misuse": static_patterns_tool_misuse_node, "static_patterns_rogue_agent": static_patterns_rogue_agent_node, + "static_patterns_agent_snooping": static_patterns_agent_snooping_node, "static_yara": static_yara_node, "behavioral_ast": behavioral_ast_node, "behavioral_taint_tracking": behavioral_taint_tracking_node, diff --git a/src/skillspector/nodes/analyzers/pattern_defaults.py b/src/skillspector/nodes/analyzers/pattern_defaults.py index 0d32e17..d098b41 100644 --- a/src/skillspector/nodes/analyzers/pattern_defaults.py +++ b/src/skillspector/nodes/analyzers/pattern_defaults.py @@ -38,6 +38,7 @@ class PatternCategory(StrEnum): YARA_MATCH = "YARA Match" MCP_LEAST_PRIVILEGE = "MCP Least Privilege" MCP_TOOL_POISONING = "MCP Tool Poisoning" + AGENT_SNOOPING = "Agent Ecosystem Snooping" # Pattern-specific explanations (why the finding is dangerous) @@ -119,6 +120,10 @@ class PatternCategory(StrEnum): "TP2": "Unicode deception detected in skill identifiers or descriptions. Homoglyphs, RTL overrides, or invisible characters can make malicious content appear benign.", "TP3": "Instruction injection patterns found in parameter descriptions or default values. Parameter metadata is read by LLMs and can override intended behavior.", "TP4": "Skill description does not match actual code behavior. The declared purpose diverges from what the code actually does, indicating possible deception.", + # Agent Ecosystem Snooping + "AS1": "Code accesses the agent's config/home directory (e.g. ~/.claude/, ~/.codex/). These hold API keys and settings; a skill has no reason to read them.", + "AS2": "Code accesses MCP server configuration (.mcp.json). This can expose MCP server tokens and endpoints to a malicious skill.", + "AS3": "Code reads another installed skill's manifest (skills/*/SKILL.md). This is lateral movement to steal another skill's logic or secrets.", } # Rule ID -> category (for report output) @@ -182,6 +187,10 @@ class PatternCategory(StrEnum): "TP2": PatternCategory.MCP_TOOL_POISONING.value, "TP3": PatternCategory.MCP_TOOL_POISONING.value, "TP4": PatternCategory.MCP_TOOL_POISONING.value, + # Agent Ecosystem Snooping + "AS1": PatternCategory.AGENT_SNOOPING.value, + "AS2": PatternCategory.AGENT_SNOOPING.value, + "AS3": PatternCategory.AGENT_SNOOPING.value, } # Rule ID -> pattern display name (for report output) @@ -245,6 +254,10 @@ class PatternCategory(StrEnum): "TP2": "Unicode Deception", "TP3": "Parameter Description Injection", "TP4": "Description-Behavior Mismatch", + # Agent Ecosystem Snooping + "AS1": "Agent Config Access", + "AS2": "MCP Config Access", + "AS3": "Cross-Skill Access", } # Pattern-specific remediations (how to fix the issue) @@ -326,6 +339,10 @@ class PatternCategory(StrEnum): "TP2": "Replace non-ASCII characters in identifiers with ASCII equivalents. Remove RTL override and invisible formatting characters.", "TP3": "Remove injection patterns, system tokens, and suspicious content from parameter descriptions and default values.", "TP4": "Update the skill description to accurately reflect all capabilities, or remove undeclared functionality.", + # Agent Ecosystem Snooping + "AS1": "Remove access to agent config/home directories. A skill should not read the agent's own settings or credentials.", + "AS2": "Remove access to MCP configuration. Skills should not read MCP server tokens or endpoints.", + "AS3": "Remove access to other skills' files. A skill should operate only within its own directory.", } diff --git a/src/skillspector/nodes/analyzers/static_patterns_agent_snooping.py b/src/skillspector/nodes/analyzers/static_patterns_agent_snooping.py new file mode 100644 index 0000000..2a7d135 --- /dev/null +++ b/src/skillspector/nodes/analyzers/static_patterns_agent_snooping.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Static patterns: agent-ecosystem snooping (AS1–AS3). Node and analyze() in one module. + +Detects a skill reaching into the agent's own environment — other installed +skills, MCP configuration, or agent config/home dirs. This is distinct from +``E3`` (generic credential paths like ~/.ssh, ~/.aws): the threat exists only +because skills run inside an agent. +""" + +from __future__ import annotations + +import re +import sys + +from skillspector.logging_config import get_logger +from skillspector.models import AnalyzerFinding, Location, Severity +from skillspector.state import AnalyzerNodeResponse, SkillspectorState + +from . import static_runner +from .common import get_context, get_line_number +from .pattern_defaults import PatternCategory + +logger = get_logger(__name__) + +ANALYZER_ID = "static_patterns_agent_snooping" + +# AS1: Agent config / home directories (where API keys and settings live). +AS1_PATTERNS = [ + (r"\.claude/", 0.7), + (r"\.codex/", 0.7), + (r"\.gemini/", 0.65), + (r"\.cursor/", 0.65), +] + +# AS2: MCP server configuration (holds server tokens / endpoints). +AS2_PATTERNS = [ + (r"\.mcp\.json", 0.75), + (r"\bmcp[_-]?servers?\b", 0.6), +] + +# AS3: Reading another installed skill's manifest. +AS3_PATTERNS = [ + (r"skills/[^/\s'\"]+/SKILL\.md", 0.7), +] + + +def analyze(content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + """Analyze content for agent-ecosystem snooping patterns (AS1–AS3).""" + findings: list[AnalyzerFinding] = [] + tag = [PatternCategory.AGENT_SNOOPING.value] + seen: set[tuple[str, int]] = set() + + def add( + rule_id: str, message: str, severity: Severity, patterns: list[tuple[str, float]] + ) -> None: + for pattern, confidence in patterns: + for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE): + line_num = get_line_number(content, match.start()) + # One finding per (rule, line) — avoid duplicate matches on a line. + if (rule_id, line_num) in seen: + continue + seen.add((rule_id, line_num)) + findings.append( + AnalyzerFinding( + rule_id=rule_id, + message=message, + severity=severity, + location=Location(file=file_path, start_line=line_num), + confidence=confidence, + tags=tag, + context=get_context(content, match.start()), + matched_text=match.group(0)[:200], + ) + ) + + add("AS1", "Agent Config Access", Severity.HIGH, AS1_PATTERNS) + add("AS2", "MCP Config Access", Severity.HIGH, AS2_PATTERNS) + add("AS3", "Cross-Skill Access", Severity.MEDIUM, AS3_PATTERNS) + return findings + + +def node(state: SkillspectorState) -> AnalyzerNodeResponse: + """Run agent-snooping patterns and return findings.""" + findings = static_runner.run_static_patterns(state, [sys.modules[__name__]]) + logger.info("%s: %d findings", ANALYZER_ID, len(findings)) + return {"findings": findings} diff --git a/tests/nodes/analyzers/test_registry.py b/tests/nodes/analyzers/test_registry.py index 0459901..7f4b651 100644 --- a/tests/nodes/analyzers/test_registry.py +++ b/tests/nodes/analyzers/test_registry.py @@ -33,6 +33,7 @@ "static_patterns_memory_poisoning", "static_patterns_tool_misuse", "static_patterns_rogue_agent", + "static_patterns_agent_snooping", "static_yara", "behavioral_ast", "behavioral_taint_tracking", diff --git a/tests/nodes/analyzers/test_static_patterns.py b/tests/nodes/analyzers/test_static_patterns.py index fbcac38..95f0514 100644 --- a/tests/nodes/analyzers/test_static_patterns.py +++ b/tests/nodes/analyzers/test_static_patterns.py @@ -17,6 +17,9 @@ from __future__ import annotations +from skillspector.nodes.analyzers import ( + static_patterns_agent_snooping as agent_snooping_module, +) from skillspector.nodes.analyzers import ( static_patterns_data_exfiltration as data_exfiltration_module, ) @@ -172,3 +175,66 @@ def test_empty_components_returns_empty(self): state = {"components": [], "file_cache": {}} findings = static_runner.run_static_patterns(state, [prompt_injection_module]) assert findings == [] + + +class TestRunStaticPatternsAgentSnooping: + """run_static_patterns with agent_snooping: AS1, AS2, AS3.""" + + def test_as1_agent_config_dir_produces_finding(self): + """Reading the agent config/home dir yields AS1 (HIGH).""" + state = { + "components": ["s.py"], + "file_cache": {"s.py": 'open("/Users/x/.claude/settings.json").read()\n'}, + } + findings = static_runner.run_static_patterns(state, [agent_snooping_module]) + as1 = [f for f in findings if f.rule_id == "AS1"] + assert len(as1) == 1 + assert as1[0].severity == "HIGH" + assert as1[0].remediation is not None + + def test_as2_mcp_config_produces_finding(self): + """Reading MCP configuration yields AS2 (HIGH).""" + state = { + "components": ["s.py"], + "file_cache": {"s.py": 'open("config/.mcp.json").read()\n'}, + } + findings = static_runner.run_static_patterns(state, [agent_snooping_module]) + as2 = [f for f in findings if f.rule_id == "AS2"] + assert len(as2) == 1 + assert as2[0].severity == "HIGH" + + def test_as3_other_skill_produces_finding(self): + """Reading another skill's manifest yields AS3.""" + state = { + "components": ["s.py"], + "file_cache": {"s.py": 'open("skills/other-skill/SKILL.md").read()\n'}, + } + findings = static_runner.run_static_patterns(state, [agent_snooping_module]) + assert any(f.rule_id == "AS3" for f in findings) + + def test_no_same_line_duplicate(self): + """A line matching one rule twice yields a single finding (built-in dedup).""" + state = { + "components": ["s.py"], + "file_cache": {"s.py": 'open("/Users/x/.claude/.codex/note")\n'}, + } + findings = static_runner.run_static_patterns(state, [agent_snooping_module]) + assert len([f for f in findings if f.rule_id == "AS1"]) == 1 + + def test_normal_file_access_not_flagged(self): + """Ordinary project file access produces no agent-snooping finding.""" + state = { + "components": ["s.py"], + "file_cache": {"s.py": 'open("data/input.csv")\nopen("./config.yaml")\n'}, + } + findings = static_runner.run_static_patterns(state, [agent_snooping_module]) + assert [f for f in findings if f.rule_id.startswith("AS")] == [] + + def test_node_runs_over_state(self): + """The node entrypoint runs the analyzer over state and returns findings.""" + state = { + "components": ["s.py"], + "file_cache": {"s.py": 'open("/Users/x/.claude/settings.json")\n'}, + } + result = agent_snooping_module.node(state) + assert any(f.rule_id == "AS1" for f in result["findings"])