Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/skillspector/nodes/analyzers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/skillspector/nodes/analyzers/pattern_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.",
}


Expand Down
100 changes: 100 additions & 0 deletions src/skillspector/nodes/analyzers/static_patterns_agent_snooping.py
Original file line number Diff line number Diff line change
@@ -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}
1 change: 1 addition & 0 deletions tests/nodes/analyzers/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions tests/nodes/analyzers/test_static_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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"])