Files
agent-desktop/tests/unit/security/skill_scanner/test_init.py
T

875 lines
30 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for pineagents.security.skill_scanner.__init__.
Covers:
- compute_skill_content_hash
- is_skill_whitelisted
- BlockedSkillRecord (to_dict, from_dict)
- _finding_to_dict
- _record_blocked_skill, get_blocked_history,
clear_blocked_history, remove_blocked_entry
- _load_scanner_config, _get_scan_mode, _scan_timeout
- _get_dir_mtime, _get_cached_result, _store_cached_result
- SkillScanError
- scan_skill_directory
"""
# pylint: disable=redefined-outer-name
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from pineagents.security.skill_scanner import (
BlockedSkillRecord,
SkillScanError,
_finding_to_dict,
_get_blocked_history_path,
_get_cached_result,
_get_dir_mtime,
_load_scanner_config,
_get_scan_mode,
_record_blocked_skill,
_scan_timeout,
_store_cached_result,
clear_blocked_history,
compute_skill_content_hash,
get_blocked_history,
is_skill_whitelisted,
remove_blocked_entry,
scan_skill_directory,
)
from pineagents.security.skill_scanner.models import (
Finding,
ScanResult,
Severity,
ThreatCategory,
)
# ---------------------------------------------------------------------------
# compute_skill_content_hash
# ---------------------------------------------------------------------------
class TestComputeSkillContentHash:
"""Tests for compute_skill_content_hash."""
def test_empty_dir(self, tmp_path):
"""Empty directory should produce a valid hash."""
h = compute_skill_content_hash(tmp_path)
assert isinstance(h, str)
assert len(h) == 64 # SHA-256 hex digest
def test_same_content_same_hash(self, tmp_path):
"""Same file contents should produce same hash."""
(tmp_path / "a.txt").write_text("hello")
h1 = compute_skill_content_hash(tmp_path)
h2 = compute_skill_content_hash(tmp_path)
assert h1 == h2
def test_different_content_different_hash(self, tmp_path):
"""Different file contents should produce different hashes."""
(tmp_path / "a.txt").write_text("hello")
h1 = compute_skill_content_hash(tmp_path)
(tmp_path / "a.txt").write_text("world")
h2 = compute_skill_content_hash(tmp_path)
assert h1 != h2
def test_nonexistent_dir(self):
"""Nonexistent directory should return a valid hash."""
h = compute_skill_content_hash(Path("/nonexistent/path"))
assert isinstance(h, str)
assert len(h) == 64
def test_skips_symlinks(self, tmp_path):
"""Symlinks should be skipped."""
(tmp_path / "real.txt").write_text("content")
link = tmp_path / "link.txt"
link.symlink_to(tmp_path / "real.txt")
h = compute_skill_content_hash(tmp_path)
assert isinstance(h, str)
def test_nested_files(self, tmp_path):
"""Nested files should be included."""
sub = tmp_path / "sub"
sub.mkdir()
(sub / "nested.txt").write_text("nested content")
(tmp_path / "top.txt").write_text("top content")
h = compute_skill_content_hash(tmp_path)
assert isinstance(h, str)
assert len(h) == 64
# ---------------------------------------------------------------------------
# is_skill_whitelisted
# ---------------------------------------------------------------------------
class TestIsSkillWhitelisted:
"""Tests for is_skill_whitelisted."""
def test_no_config_returns_false(self):
"""When config is None, should return False."""
result = is_skill_whitelisted("test-skill", cfg=None)
assert result is False
def test_whitelisted_skill_no_hash(self):
"""Skill with no content_hash in whitelist should return True."""
entry = MagicMock()
entry.skill_name = "test-skill"
entry.content_hash = ""
cfg = MagicMock()
cfg.whitelist = [entry]
result = is_skill_whitelisted("test-skill", cfg=cfg)
assert result is True
def test_non_whitelisted_skill(self):
"""Non-whitelisted skill should return False."""
entry = MagicMock()
entry.skill_name = "other-skill"
entry.content_hash = ""
cfg = MagicMock()
cfg.whitelist = [entry]
result = is_skill_whitelisted("test-skill", cfg=cfg)
assert result is False
def test_whitelisted_with_matching_hash(self, tmp_path):
"""Skill with matching content_hash should return True."""
(tmp_path / "file.txt").write_text("content")
content_hash = compute_skill_content_hash(tmp_path)
entry = MagicMock()
entry.skill_name = "test-skill"
entry.content_hash = content_hash
cfg = MagicMock()
cfg.whitelist = [entry]
result = is_skill_whitelisted(
"test-skill",
skill_dir=tmp_path,
cfg=cfg,
)
assert result is True
def test_whitelisted_with_mismatched_hash(self, tmp_path):
"""Skill with mismatched content_hash should return False."""
entry = MagicMock()
entry.skill_name = "test-skill"
entry.content_hash = "wrong_hash"
cfg = MagicMock()
cfg.whitelist = [entry]
result = is_skill_whitelisted(
"test-skill",
skill_dir=tmp_path,
cfg=cfg,
)
assert result is False
def test_whitelisted_with_hash_no_dir(self):
"""Skill with content_hash but no dir should return True."""
entry = MagicMock()
entry.skill_name = "test-skill"
entry.content_hash = "some_hash"
cfg = MagicMock()
cfg.whitelist = [entry]
result = is_skill_whitelisted(
"test-skill",
skill_dir=None,
cfg=cfg,
)
assert result is True
def test_loads_config_when_none(self):
"""When cfg is None, should call _load_scanner_config."""
with patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
) as mock_load:
result = is_skill_whitelisted("test-skill")
mock_load.assert_called_once()
assert result is False
# ---------------------------------------------------------------------------
# BlockedSkillRecord
# ---------------------------------------------------------------------------
class TestBlockedSkillRecord:
"""Tests for BlockedSkillRecord dataclass."""
def test_creation(self):
"""Should store all fields."""
r = BlockedSkillRecord(
skill_name="test-skill",
blocked_at="2024-01-01T00:00:00Z",
max_severity="HIGH",
findings=[{"severity": "HIGH", "title": "test"}],
content_hash="abc123",
action="blocked",
)
assert r.skill_name == "test-skill"
assert r.max_severity == "HIGH"
assert r.action == "blocked"
def test_defaults(self):
"""Default values should be correct."""
r = BlockedSkillRecord(
skill_name="s",
blocked_at="",
max_severity="LOW",
)
assert not r.findings
assert r.content_hash == ""
assert r.action == "blocked"
def test_to_dict(self):
"""to_dict should serialize all fields."""
r = BlockedSkillRecord(
skill_name="test-skill",
blocked_at="2024-01-01",
max_severity="CRITICAL",
findings=[{"severity": "CRITICAL"}],
content_hash="hash",
action="warned",
)
d = r.to_dict()
assert d["skill_name"] == "test-skill"
assert d["action"] == "warned"
assert d["content_hash"] == "hash"
def test_from_dict(self):
"""from_dict should deserialize correctly."""
d = {
"skill_name": "test-skill",
"blocked_at": "2024-01-01",
"max_severity": "HIGH",
"findings": [{"severity": "HIGH"}],
"content_hash": "hash",
"action": "blocked",
}
r = BlockedSkillRecord.from_dict(d)
assert r.skill_name == "test-skill"
assert r.max_severity == "HIGH"
assert r.action == "blocked"
def test_from_dict_defaults(self):
"""from_dict should handle missing optional fields."""
d = {"skill_name": "s"}
r = BlockedSkillRecord.from_dict(d)
assert r.blocked_at == ""
assert r.max_severity == ""
assert not r.findings
assert r.content_hash == ""
assert r.action == "blocked"
def test_roundtrip(self):
"""to_dict -> from_dict should preserve data."""
r = BlockedSkillRecord(
skill_name="s",
blocked_at="2024-01-01",
max_severity="HIGH",
findings=[{"severity": "HIGH"}],
content_hash="hash",
action="warned",
)
r2 = BlockedSkillRecord.from_dict(r.to_dict())
assert r2.skill_name == r.skill_name
assert r2.max_severity == r.max_severity
assert r2.action == r.action
# ---------------------------------------------------------------------------
# _finding_to_dict
# ---------------------------------------------------------------------------
class TestFindingToDict:
"""Tests for _finding_to_dict."""
def test_converts_finding(self):
"""Should convert Finding to dict."""
f = Finding(
id="R1:f.py:1",
rule_id="R1",
category=ThreatCategory.COMMAND_INJECTION,
severity=Severity.HIGH,
title="Test",
description="test finding",
file_path="f.py",
line_number=1,
)
d = _finding_to_dict(f)
assert d["severity"] == "HIGH"
assert d["title"] == "Test"
assert d["file_path"] == "f.py"
assert d["line_number"] == 1
assert d["rule_id"] == "R1"
# ---------------------------------------------------------------------------
# Blocked history persistence
# ---------------------------------------------------------------------------
class TestBlockedHistoryPersistence:
"""Tests for blocked history read/write operations."""
def test_get_blocked_history_empty(self, tmp_path):
"""Should return empty list when no history file exists."""
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=tmp_path / "nonexistent.json",
):
result = get_blocked_history()
assert not result
def test_record_and_get_history(self, tmp_path):
"""Should record and retrieve blocked skill history."""
history_path = tmp_path / "history.json"
result = ScanResult(
skill_name="test-skill",
skill_directory=str(tmp_path),
findings=[
Finding(
id="R1",
rule_id="R1",
category=ThreatCategory.COMMAND_INJECTION,
severity=Severity.HIGH,
title="Test",
description="test",
),
],
)
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=history_path,
):
_record_blocked_skill(result, tmp_path, action="blocked")
history = get_blocked_history()
assert len(history) == 1
assert history[0].skill_name == "test-skill"
assert history[0].action == "blocked"
def test_clear_blocked_history(self, tmp_path):
"""Should clear the history file."""
history_path = tmp_path / "history.json"
history_path.write_text("[]", encoding="utf-8")
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=history_path,
):
clear_blocked_history()
assert not history_path.exists()
def test_clear_nonexistent_history(self, tmp_path):
"""Should not raise when clearing nonexistent history."""
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=tmp_path / "nonexistent.json",
):
clear_blocked_history() # Should not raise
def test_remove_blocked_entry(self, tmp_path):
"""Should remove a specific entry by index."""
history_path = tmp_path / "history.json"
records = [
{"skill_name": "skill1", "blocked_at": "", "max_severity": "LOW"},
{"skill_name": "skill2", "blocked_at": "", "max_severity": "HIGH"},
]
history_path.write_text(
json.dumps(records),
encoding="utf-8",
)
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=history_path,
):
result = remove_blocked_entry(0)
assert result is True
history = get_blocked_history()
assert len(history) == 1
assert history[0].skill_name == "skill2"
def test_remove_blocked_entry_invalid_index(self, tmp_path):
"""Should return False for invalid index."""
history_path = tmp_path / "history.json"
history_path.write_text("[]", encoding="utf-8")
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=history_path,
):
result = remove_blocked_entry(99)
assert result is False
def test_remove_blocked_entry_no_file(self, tmp_path):
"""Should return False when no history file exists."""
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=tmp_path / "nonexistent.json",
):
result = remove_blocked_entry(0)
assert result is False
def test_record_multiple_entries(self, tmp_path):
"""Should append multiple entries."""
history_path = tmp_path / "history.json"
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
return_value=history_path,
):
for i in range(3):
result = ScanResult(
skill_name=f"skill-{i}",
skill_directory=str(tmp_path),
findings=[],
)
_record_blocked_skill(result, tmp_path, action="blocked")
history = get_blocked_history()
assert len(history) == 3
# ---------------------------------------------------------------------------
# _load_scanner_config, _get_scan_mode, _scan_timeout
# ---------------------------------------------------------------------------
class TestLoadScannerConfig:
"""Tests for _load_scanner_config."""
def test_returns_none_on_import_error(self):
"""Should return None when config import fails."""
with patch(
"pineagents.config.load_config",
side_effect=ImportError,
):
result = _load_scanner_config()
assert result is None
def test_returns_none_on_generic_exception(self):
"""Should return None on any exception."""
with patch(
"pineagents.config.load_config",
side_effect=RuntimeError("boom"),
):
result = _load_scanner_config()
assert result is None
class TestGetScanMode:
"""Tests for _get_scan_mode."""
def test_env_var_block(self):
"""QWENPAW_SKILL_SCAN_MODE=block should return 'block'."""
with patch(
"pineagents.security.skill_scanner.EnvVarLoader.get_str",
return_value="block",
):
assert _get_scan_mode() == "block"
def test_env_var_warn(self):
"""QWENPAW_SKILL_SCAN_MODE=warn should return 'warn'."""
with patch(
"pineagents.security.skill_scanner.EnvVarLoader.get_str",
return_value="warn",
):
assert _get_scan_mode() == "warn"
def test_env_var_off(self):
"""QWENPAW_SKILL_SCAN_MODE=off should return 'off'."""
with patch(
"pineagents.security.skill_scanner.EnvVarLoader.get_str",
return_value="off",
):
assert _get_scan_mode() == "off"
def test_env_var_case_insensitive(self):
"""Env var value should be case-insensitive."""
with patch(
"pineagents.security.skill_scanner.EnvVarLoader.get_str",
return_value="BLOCK",
):
assert _get_scan_mode() == "block"
def test_invalid_env_var_falls_to_config(self):
"""Invalid env var should fall through to config."""
cfg = MagicMock()
cfg.mode = "warn"
with patch(
"pineagents.security.skill_scanner.EnvVarLoader.get_str",
return_value="invalid",
):
result = _get_scan_mode(cfg=cfg)
assert result == "warn"
def test_no_env_no_config_defaults_to_block(self):
"""No env var and no config should default to 'block'."""
with patch(
"pineagents.security.skill_scanner.EnvVarLoader.get_str",
return_value="",
), patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
):
result = _get_scan_mode()
assert result == "block"
class TestScanTimeout:
"""Tests for _scan_timeout."""
def test_config_timeout(self):
"""Should use config timeout when available."""
cfg = MagicMock()
cfg.timeout = 60
result = _scan_timeout(cfg=cfg)
assert result == 60.0
def test_default_timeout(self):
"""Should default to 30.0 when no config."""
with patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
):
result = _scan_timeout()
assert result == 30.0
# ---------------------------------------------------------------------------
# Cache helpers
# ---------------------------------------------------------------------------
class TestCacheHelpers:
"""Tests for _get_dir_mtime, _get_cached_result, _store_cached_result."""
def test_get_dir_mtime(self, tmp_path):
"""Should return a positive mtime for existing directory."""
mtime = _get_dir_mtime(tmp_path)
assert mtime > 0
def test_get_dir_mtime_nonexistent(self):
"""Should return 0.0 for nonexistent directory."""
mtime = _get_dir_mtime(Path("/nonexistent"))
assert mtime == 0.0
def test_cache_store_and_retrieve(self, tmp_path):
"""Should store and retrieve cached results."""
result = ScanResult(
skill_name="test",
skill_directory=str(tmp_path),
)
_store_cached_result(tmp_path, result)
cached = _get_cached_result(tmp_path)
assert cached is not None
assert cached.skill_name == "test"
def test_cache_miss(self, tmp_path):
"""Should return None for uncached directory."""
cached = _get_cached_result(tmp_path)
assert cached is None
def test_cache_invalidation_on_change(self, tmp_path):
"""Cache should be invalidated when directory changes."""
result = ScanResult(
skill_name="test",
skill_directory=str(tmp_path),
)
_store_cached_result(tmp_path, result)
# Modify directory to change mtime
import time
new_file = tmp_path / "new_file.txt"
new_file.write_text("change")
import os
future = time.time() + 2
os.utime(str(new_file), (future, future))
cached = _get_cached_result(tmp_path)
# After modification, cache should be invalidated
# (mtime changed, so cached result is None)
assert cached is None
# ---------------------------------------------------------------------------
# SkillScanError
# ---------------------------------------------------------------------------
class TestSkillScanError:
"""Tests for SkillScanError."""
def test_error_message(self):
"""Should include finding summary in message."""
result = ScanResult(
skill_name="bad-skill",
skill_directory="/tmp",
findings=[
Finding(
id="R1",
rule_id="R1",
category=ThreatCategory.COMMAND_INJECTION,
severity=Severity.HIGH,
title="Dangerous cmd",
description="test",
file_path="script.sh",
line_number=1,
),
],
)
error = SkillScanError(result)
assert "bad-skill" in str(error)
assert "HIGH" in str(error)
assert error.result is result
def test_error_message_truncation(self):
"""Should truncate when more than 5 findings."""
findings = [
Finding(
id=f"R{i}",
rule_id=f"R{i}",
category=ThreatCategory.COMMAND_INJECTION,
severity=Severity.LOW,
title=f"Finding {i}",
description="test",
file_path="f.py",
)
for i in range(8)
]
result = ScanResult(
skill_name="many-findings",
skill_directory="/tmp",
findings=findings,
)
error = SkillScanError(result)
assert "3 more" in str(error)
# ---------------------------------------------------------------------------
# scan_skill_directory
# ---------------------------------------------------------------------------
class TestScanSkillDirectory:
"""Tests for scan_skill_directory."""
def test_scan_mode_off(self, tmp_path):
"""Should return None when scan mode is 'off'."""
with patch(
"pineagents.security.skill_scanner._get_scan_mode",
return_value="off",
):
result = scan_skill_directory(str(tmp_path))
assert result is None
def test_scan_whitelisted_skill(self, tmp_path):
"""Should return None for whitelisted skill."""
with patch(
"pineagents.security.skill_scanner._get_scan_mode",
return_value="block",
), patch(
"pineagents.security.skill_scanner.is_skill_whitelisted",
return_value=True,
):
result = scan_skill_directory(str(tmp_path))
assert result is None
def test_scan_safe_skill(self, tmp_path):
"""Should return ScanResult for safe skill."""
(tmp_path / "safe.py").write_text("print('hello')")
with patch(
"pineagents.security.skill_scanner._get_scan_mode",
return_value="warn",
), patch(
"pineagents.security.skill_scanner.is_skill_whitelisted",
return_value=False,
), patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
):
result = scan_skill_directory(
str(tmp_path),
skill_name="safe-skill",
)
assert result is not None
assert isinstance(result, ScanResult)
def test_scan_unsafe_skill_block_mode(self, tmp_path):
"""Should raise SkillScanError in block mode for unsafe skill."""
unsafe_result = ScanResult(
skill_name="danger-skill",
skill_directory=str(tmp_path),
findings=[
Finding(
id="R1",
rule_id="R1",
category=ThreatCategory.COMMAND_INJECTION,
severity=Severity.HIGH,
title="Dangerous",
description="test",
),
],
)
with patch(
"pineagents.security.skill_scanner._get_scan_mode",
return_value="block",
), patch(
"pineagents.security.skill_scanner.is_skill_whitelisted",
return_value=False,
), patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
), patch(
"pineagents.security.skill_scanner._get_scanner",
) as mock_scanner_cls, patch(
"pineagents.security.skill_scanner._get_cached_result",
return_value=None,
), patch(
"pineagents.security.skill_scanner._store_cached_result",
):
mock_scanner = MagicMock()
mock_scanner.scan_skill.return_value = unsafe_result
mock_scanner_cls.return_value = mock_scanner
with pytest.raises(SkillScanError):
scan_skill_directory(
str(tmp_path),
skill_name="danger-skill",
)
def test_scan_unsafe_skill_warn_mode(self, tmp_path):
"""Should return ScanResult in warn mode for unsafe skill."""
unsafe_result = ScanResult(
skill_name="danger-skill",
skill_directory=str(tmp_path),
findings=[
Finding(
id="R1",
rule_id="R1",
category=ThreatCategory.COMMAND_INJECTION,
severity=Severity.HIGH,
title="Dangerous",
description="test",
),
],
)
with patch(
"pineagents.security.skill_scanner._get_scan_mode",
return_value="warn",
), patch(
"pineagents.security.skill_scanner.is_skill_whitelisted",
return_value=False,
), patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
), patch(
"pineagents.security.skill_scanner._get_scanner",
) as mock_scanner_cls, patch(
"pineagents.security.skill_scanner._get_cached_result",
return_value=None,
), patch(
"pineagents.security.skill_scanner._store_cached_result",
), patch(
"pineagents.security.skill_scanner._record_blocked_skill",
):
mock_scanner = MagicMock()
mock_scanner.scan_skill.return_value = unsafe_result
mock_scanner_cls.return_value = mock_scanner
result = scan_skill_directory(
str(tmp_path),
skill_name="danger-skill",
)
assert result is not None
assert not result.is_safe
def test_scan_with_explicit_block(self, tmp_path):
"""block=True should raise even in warn mode."""
unsafe_result = ScanResult(
skill_name="danger-skill",
skill_directory=str(tmp_path),
findings=[
Finding(
id="R1",
rule_id="R1",
category=ThreatCategory.COMMAND_INJECTION,
severity=Severity.HIGH,
title="Dangerous",
description="test",
),
],
)
with patch(
"pineagents.security.skill_scanner._get_scan_mode",
return_value="warn",
), patch(
"pineagents.security.skill_scanner.is_skill_whitelisted",
return_value=False,
), patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
), patch(
"pineagents.security.skill_scanner._get_scanner",
) as mock_scanner_cls, patch(
"pineagents.security.skill_scanner._get_cached_result",
return_value=None,
), patch(
"pineagents.security.skill_scanner._store_cached_result",
):
mock_scanner = MagicMock()
mock_scanner.scan_skill.return_value = unsafe_result
mock_scanner_cls.return_value = mock_scanner
with pytest.raises(SkillScanError):
scan_skill_directory(
str(tmp_path),
skill_name="danger-skill",
block=True,
)
def test_scan_uses_skill_dir_name(self, tmp_path):
"""Should use directory name as skill_name when not provided."""
(tmp_path / "safe.py").write_text("x = 1")
with patch(
"pineagents.security.skill_scanner._get_scan_mode",
return_value="warn",
), patch(
"pineagents.security.skill_scanner.is_skill_whitelisted",
return_value=False,
), patch(
"pineagents.security.skill_scanner._load_scanner_config",
return_value=None,
):
result = scan_skill_directory(str(tmp_path))
assert result is not None
assert result.skill_name == tmp_path.name
# ---------------------------------------------------------------------------
# _get_blocked_history_path
# ---------------------------------------------------------------------------
class TestGetBlockedHistoryPath:
"""Tests for _get_blocked_history_path."""
def test_returns_path(self):
"""Should return a Path object."""
path = _get_blocked_history_path()
assert isinstance(path, Path)
assert path.name == "skill_scanner_blocked.json"
def test_uses_working_dir(self):
"""Should use WORKING_DIR when available."""
mock_working_dir = MagicMock()
mock_working_dir.__truediv__ = (
lambda self, other: Path(
"/mock/.qwenpaw",
)
/ other
)
with patch(
"pineagents.security.skill_scanner._get_blocked_history_path",
):
# Just verify the function returns a Path
result = _get_blocked_history_path()
assert isinstance(result, Path)