Refine agent skill boundaries and secret handling

This commit is contained in:
jxxghp
2026-06-21 09:25:44 +08:00
parent 18803c7995
commit 99e369aaa4
12 changed files with 783 additions and 794 deletions

View File

@@ -0,0 +1,22 @@
from app.agent.prompt import PromptManager
from app.core.config import settings
def test_moviepilot_info_does_not_expose_api_token_or_database_password(monkeypatch) -> None:
"""系统提示词中的运行信息不能暴露 API 令牌或数据库密码。"""
monkeypatch.setattr(settings, "API_TOKEN", "prompt-secret-token")
monkeypatch.setattr(settings, "DB_TYPE", "postgresql")
monkeypatch.setattr(settings, "DB_POSTGRESQL_HOST", "db.example.local")
monkeypatch.setattr(settings, "DB_POSTGRESQL_PORT", "5432")
monkeypatch.setattr(settings, "DB_POSTGRESQL_DATABASE", "moviepilot")
monkeypatch.setattr(settings, "DB_POSTGRESQL_USERNAME", "moviepilot_user")
monkeypatch.setattr(settings, "DB_POSTGRESQL_PASSWORD", "prompt-db-password")
manager = PromptManager()
moviepilot_info = manager._get_moviepilot_info()
assert "prompt-secret-token" not in moviepilot_info
assert "prompt-db-password" not in moviepilot_info
assert "moviepilot_user:prompt-db-password" not in moviepilot_info
assert "API认证: 由内部工具自动处理" in moviepilot_info
assert "凭据由内部工具读取" in moviepilot_info

View File

@@ -0,0 +1,65 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SKILLS_ROOT = PROJECT_ROOT / "skills"
def _read_skill(skill_name: str) -> str:
"""读取内置技能的 SKILL.md 内容。"""
return (SKILLS_ROOT / skill_name / "SKILL.md").read_text(encoding="utf-8")
def _frontmatter_value(content: str, key: str) -> str:
"""从 SKILL.md frontmatter 中读取单行字段值。"""
for line in content.splitlines():
if line.startswith(f"{key}:"):
return line.split(":", 1)[1].strip()
return ""
def test_modified_builtin_skills_have_incremented_versions() -> None:
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
expected_versions = {
"database-operation": "3",
"moviepilot-api": "2",
"moviepilot-cli": "3",
"moviepilot-update": "3",
}
for skill_name, expected_version in expected_versions.items():
content = _read_skill(skill_name)
assert _frontmatter_value(content, "version") == expected_version
def test_moviepilot_cli_skill_uses_local_tool_boundary() -> None:
"""CLI 技能应只描述本地 MCP tool 边界,不再默认使用旧 Node 脚本。"""
content = _read_skill("moviepilot-cli")
assert "moviepilot tool" in content
assert "scripts/mp-cli.js" not in content
assert "Use `scripts/mp-cli.js`" not in content
assert "node scripts/mp-cli.js" not in content
assert "any request involving movies" not in content
assert "whenever the user explicitly mentions MoviePilot" not in content
assert "Do not ask the user" in content
assert "moviepilot-api" in content
assert "database-operation" in content
def test_api_and_database_skills_declare_fallback_boundaries() -> None:
"""API 和数据库技能应明确各自兜底边界,避免抢占普通产品操作。"""
api_content = _read_skill("moviepilot-api")
db_content = _read_skill("database-operation")
assert "REST API bridge" in api_content
assert "Do not use this skill just because MoviePilot is mentioned" in api_content
assert "moviepilot-cli" in api_content
assert "Direct SQL query or database update" in api_content
assert "direct SQL boundary" in db_content
assert "Use this skill as the final fallback" in db_content
assert "INSERT" in db_content
assert "UPDATE" in db_content
assert "DELETE" in db_content

View File

@@ -0,0 +1,143 @@
import importlib.util
import json
from pathlib import Path
from types import ModuleType
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MP_API_SCRIPT = PROJECT_ROOT / "skills" / "moviepilot-api" / "scripts" / "mp-api.py"
MP_DB_SCRIPT = PROJECT_ROOT / "skills" / "database-operation" / "scripts" / "mp-db.py"
def _load_script(path: Path, module_name: str) -> ModuleType:
"""按文件路径加载 skill 脚本模块。"""
spec = importlib.util.spec_from_file_location(module_name, path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_mp_api_uses_settings_without_prompt_token(monkeypatch, tmp_path) -> None:
"""API 脚本应直接读取 settings而不是要求提示词提供 token。"""
module = _load_script(MP_API_SCRIPT, "mp_api_script")
runtime_dir = tmp_path / "temp"
runtime_dir.mkdir()
class FakeSettings:
"""提供 API 脚本本地配置所需字段。"""
TEMP_PATH = runtime_dir
HOST = "0.0.0.0"
PORT = 3001
API_TOKEN = "settings-token"
monkeypatch.setattr(module, "_ensure_project_import", lambda: None)
monkeypatch.setattr(module, "read_config", lambda: ("http://file-host", "file-token"))
monkeypatch.setattr(
"app.core.config.settings",
FakeSettings,
raising=False,
)
monkeypatch.delenv("MP_HOST", raising=False)
monkeypatch.delenv("MP_API_KEY", raising=False)
host, key = module.resolve_config()
assert host == "http://127.0.0.1:3001"
assert key == "settings-token"
def test_mp_db_rejects_write_statement_without_write_flag() -> None:
"""数据库脚本默认必须拒绝写操作。"""
module = _load_script(MP_DB_SCRIPT, "mp_db_script")
assert module._is_supported_statement("SELECT COUNT(*) FROM downloadhistory", False)
assert not module._is_supported_statement("UPDATE subscribe SET state='S' WHERE id=1", False)
assert not module._is_supported_statement(
"SELECT COUNT(*) FROM downloadhistory; DELETE FROM downloadhistory WHERE id=1",
False,
)
def test_mp_db_returns_sensitive_columns_without_masking(monkeypatch, capsys) -> None:
"""数据库脚本应原样返回敏感字段供 Agent 内部使用。"""
module = _load_script(MP_DB_SCRIPT, "mp_db_script_unmasked")
class FakeRow:
"""模拟 SQLAlchemy 查询行。"""
_mapping = {"token": "raw-token", "password": "raw-password"}
class FakeResult:
"""模拟返回查询结果的 SQLAlchemy Result。"""
returns_rows = True
def fetchall(self) -> list[FakeRow]:
"""返回测试查询行。"""
return [FakeRow()]
class FakeConnection:
"""模拟数据库连接。"""
def execute(self, statement: Any) -> FakeResult:
"""返回测试结果。"""
return FakeResult()
class FakeTransaction:
"""模拟 engine.begin() 上下文。"""
def __enter__(self) -> FakeConnection:
"""进入上下文时返回连接。"""
return FakeConnection()
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> bool:
"""退出上下文。"""
return False
class FakeEngine:
"""模拟数据库引擎。"""
def begin(self) -> FakeTransaction:
"""返回事务上下文。"""
return FakeTransaction()
monkeypatch.setattr(module, "_build_engine", lambda: FakeEngine())
assert module.run_query("SELECT token, password FROM site LIMIT 1") == 0
output = json.loads(capsys.readouterr().out)
assert output["rows"] == [{"token": "raw-token", "password": "raw-password"}]
def test_mp_db_write_command_allows_write_statement(monkeypatch) -> None:
"""数据库脚本 write 子命令应直接允许写操作。"""
module = _load_script(MP_DB_SCRIPT, "mp_db_script_write")
calls = []
def fake_run_query(sql: str, *, limit: int = 100, allow_write: bool = False) -> int:
"""记录 write 子命令传入的执行参数。"""
calls.append({"sql": sql, "limit": limit, "allow_write": allow_write})
return 0
monkeypatch.setattr(module, "run_query", fake_run_query)
monkeypatch.setattr(
module.sys,
"argv",
[
"mp-db.py",
"write",
"UPDATE subscribe SET state = 'S' WHERE id = 123",
],
)
assert module.main() == 0
assert calls == [
{
"sql": "UPDATE subscribe SET state = 'S' WHERE id = 123",
"limit": 0,
"allow_write": True,
}
]