fix(database-operation): 拦截未显式授权的凭据配置表读取

systemconfig 按 SystemConfigKey 聚合实例级 API Key、Cookie 与口令,托管
config.system.get 默认脱敏,但数据库技能脚本与 MCP database_operation 接受
任意 SQL,一条 SELECT 就能整包取回明文。

现在由脚本本身按表拦截:引用 systemconfig 的语句(含引号标识符、模式限定名、
CTE 与子查询)在读写两种模式下都默认拒绝,只有显式 --show-secrets /
show_secrets=true 才放行,失败信息直接给出可执行的下一步,因此 MCP 工具、
技能调用与命令行入口共享同一道边界。宿主策略把开启该开关的查询判定为
SENSITIVE_READ:要求系统管理员确认,结果标记为 SECRET,回执摘要不再保留内容。

门禁范围收敛为单表:userconfig 只承载界面偏好,plugindata 是文档化的诊断路径,
其余表的凭据残留风险改由 SKILL.md 的窄投影要求约束,避免以不可绕过的方式破坏
既有可运维性。MCP 文档、技能说明与相关契约测试同步更新。
This commit is contained in:
JontyLee
2026-10-26 20:20:16 +08:00
parent a11bc444f4
commit 0ba73f5b87
11 changed files with 423 additions and 35 deletions

View File

@@ -35,6 +35,19 @@ def requests_system_setting_secrets(arguments: Mapping[str, Any]) -> bool:
return False
def requests_database_secret_rows(arguments: Mapping[str, Any]) -> bool:
"""
判断数据库工具参数是否请求读取集中存放凭据的配置表行。
"""
if str(arguments.get("action") or "") != "query":
return False
action_arguments = arguments.get("arguments")
return (
isinstance(action_arguments, Mapping)
and action_arguments.get("show_secrets") is True
)
# 该清单只校验固定工具 inventory;未命中的固定或动态工具同样默认 LEGACY_SHADOW。
BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset(
{
@@ -183,6 +196,18 @@ class ToolPolicyRegistry:
result_sensitivity=operation.result_sensitivity,
migration_state=MigrationState.ENFORCED,
)
if tool_name == "database_operation" and requests_database_secret_rows(arguments):
return ActionPolicy(
effect=ActionEffect.SENSITIVE_READ,
required_role=PrincipalRole.SYSTEM_ADMIN,
confirmation=ConfirmationMode.REQUIRED,
recovery=RecoveryMode.MANUAL_ONLY,
result_sensitivity=ResultSensitivity.SECRET,
migration_state=MigrationState.ENFORCED,
machine_allowed=True,
background_allowed=False,
subagent_allowed=False,
)
if tool_name in self._safe_read_tool_names:
return ActionPolicy(
effect=ActionEffect.SAFE_READ,

View File

@@ -539,21 +539,18 @@ class MediaServerOperationTool(_ServiceOperationTool):
)
def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[str, Any]:
def _validate_database_arguments(
action: str,
action_arguments: Dict[str, Any],
contract_data: Dict[str, Any],
) -> None:
"""
不经 shell 调用数据库 Skill 脚本,并返回结构化结果。
"""
script_path = root_path / "skills/database-operation/scripts/mp-db.py"
action = str(arguments.get("action") or "")
action_arguments = arguments.get("arguments") or {}
if not isinstance(action_arguments, dict):
raise ValueError("数据库 action arguments 必须是对象")
按动作合同校验数据库嵌套参数,在连接数据库前拒绝未知、缺失或类型错误的字段。
contracts = _load_action_contracts(str(script_path))
contract = contracts.get(action)
if contract is None:
raise ValueError(f"未知数据库 action: {action}")
contract_data = contract.to_dict(action)
:param action: 当前数据库动作名称
:param action_arguments: 当前动作的结构化参数
:param contract_data: 当前动作的公开契约字典
"""
declared_arguments = {item["name"]: item for item in contract_data["arguments"]}
unknown_arguments = sorted(set(action_arguments) - set(declared_arguments))
if unknown_arguments:
@@ -576,6 +573,9 @@ def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[
or not action_arguments[field_name].strip()
):
raise ValueError(f"数据库参数 {field_name} 必须是非空 string")
reveal_flag = action_arguments.get("show_secrets", False)
if not isinstance(reveal_flag, bool):
raise ValueError("数据库参数 show_secrets 必须是 boolean")
if action == "schema" and (
not isinstance(action_arguments.get("table_name"), str)
or not action_arguments["table_name"].strip()
@@ -589,6 +589,20 @@ def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[
if not isinstance(write_flag, bool):
raise ValueError("数据库参数 write 必须是 boolean")
def _build_database_command(
action: str,
action_arguments: Dict[str, Any],
script_path: Path,
) -> list[str]:
"""
组装数据库 Skill 脚本的 argv,不经过 shell。
:param action: 当前数据库动作名称
:param action_arguments: 已通过合同校验的结构化参数
:param script_path: 固定 Skill 脚本的绝对路径
:return: 传给 subprocess 的参数列表
"""
command = [sys.executable, str(script_path), action]
if action == "schema":
command.append(str(action_arguments.get("table_name") or ""))
@@ -603,6 +617,27 @@ def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[
command.extend(["--limit", str(action_arguments.get("limit", 100))])
if action_arguments.get("write") is True:
command.append("--write")
if action_arguments.get("show_secrets") is True:
command.append("--show-secrets")
return command
def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[str, Any]:
"""
不经 shell 调用数据库 Skill 脚本,并返回结构化结果。
"""
script_path = root_path / "skills/database-operation/scripts/mp-db.py"
action = str(arguments.get("action") or "")
action_arguments = arguments.get("arguments") or {}
if not isinstance(action_arguments, dict):
raise ValueError("数据库 action arguments 必须是对象")
contracts = _load_action_contracts(str(script_path))
contract = contracts.get(action)
if contract is None:
raise ValueError(f"未知数据库 action: {action}")
_validate_database_arguments(action, action_arguments, contract.to_dict(action))
command = _build_database_command(action, action_arguments, script_path)
completed = subprocess.run(
command,

View File

@@ -355,11 +355,13 @@ Emby、Jellyfin、Plex、Navidrome 的目录、条目、搜索、季集和活动
| --- | --- |
| `tables` | 空对象,返回实际表清单 |
| `schema` | `table_name`,必须使用表清单中的精确名称 |
| `query` | `sql` 与 `file` 二选一;`limit` 默认 100,范围 1–200;默认只读,`write` 默认 `false` |
| `write` | `sql` 与 `file` 二选一;执行单条写入或结构变更语句,必须已有对应用户授权 |
| `query` | `sql` 与 `file` 二选一;`limit` 默认 100,范围 1–200;默认只读,`write` 默认 `false`;`show_secrets` 默认 `false` |
| `write` | `sql` 与 `file` 二选一;执行单条写入或结构变更语句,必须已有对应用户授权;引用 `systemconfig` 时同样需要 `show_secrets=true` |
`file` 指 MoviePilot 运行环境中的 SQL 文件,不是客户端本地文件;外部接入通常直接传 `sql`。工具自行读取 MoviePilot 数据库配置,不接受任意连接串或数据库密码。
`systemconfig` 集中存放实例级 API Key、Cookie 与口令,语句只要引用该表(含引号标识符、模式限定名和 CTE 写法)就被拒绝,除非显式传 `show_secrets=true`。该门禁按表生效,因此计数或只取键名也需要开启;宿主会把这类读取判定为敏感读取并要求确认,回执中不保留结果内容。读取普通设置仍应使用 `config.system.*`。
读取行时使用必要字段和有限窗口;写入前核对目标与条件,完成后只读核验。普通设置修改使用 `config.system.*`,避免绕过配置校验、事件通知和专用业务流程。更多约束见[数据库操作参考](../skills/database-operation/SKILL.md)。
## 5. 组合业务调用

View File

@@ -18,7 +18,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
MOVIEPILOT_API_SKILL_VERSION = "65"
DATABASE_OPERATION_SKILL_VERSION = "15"
DATABASE_OPERATION_SKILL_VERSION = "16"
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {

View File

@@ -1,6 +1,6 @@
---
name: database-operation
version: 15
version: 16
description: >-
Use this skill when you need to inspect, query, maintain, or carefully modify
the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper,
@@ -66,8 +66,10 @@ System settings have two managed sources and should not normally be edited here:
configuration-change events.
Use direct SQL against `systemconfig` only for an explicitly authorized repair
when the managed API cannot complete the operation. Inspect the exact row first,
avoid broad writes, and verify the managed API can read the repaired value.
when the managed API cannot complete the operation. The script blocks any
statement that references `systemconfig` until you pass `--show-secrets`, so
inspect the exact row first, avoid broad writes, and verify the managed API can
read the repaired value.
## Commands
@@ -103,6 +105,18 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
`query --write` is also supported for compatibility, but prefer the `write` subcommand for `INSERT`, `UPDATE`, `DELETE`, and schema changes.
Statements that reference `systemconfig` are rejected until you add
`--show-secrets`, in read and write mode alike:
```bash
python scripts/mp-db.py query "SELECT key, value FROM systemconfig WHERE key = 'TMDB_API_KEY'" --show-secrets
```
The gate is per table, not per column, so even a count or key listing over
`systemconfig` needs the flag. Read normal settings through
`moviepilot-api` `config.system.get` instead, which redacts secrets for you,
and pass the flag only for an explicitly authorized repair.
## Workflow
1. Prefer existing MoviePilot tools or APIs for normal product workflows.
@@ -121,14 +135,22 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
do not change a query into a write or a multi-statement request. Unclosed
lexical structures remain invalid.
- Plain `SELECT` queries get a default `LIMIT 100` if no limit is present.
- Query results are returned exactly as stored. The agent may use sensitive values internally when needed, but must not echo secrets in the final user-facing response unless the user explicitly asks to inspect that value.
- `systemconfig` aggregates every instance-level API key, cookie and password,
so it stays unreadable and unwritable until you pass `--show-secrets`. Quoted
identifiers and schema-qualified names are recognized, so aliasing the table
does not avoid the gate.
- Other tables are not gated. `site` and plugin data still hold per-site
credentials, so keep the projection narrow instead of selecting whole rows.
- Results are returned exactly as stored. The agent may use sensitive values
internally when needed, but must not echo secrets in the final user-facing
response unless the user explicitly asks to inspect that value.
## Safety Rules
1. Confirm before destructive or broad write operations when the user has not already clearly authorized the exact change.
2. Suggest a backup before destructive operations such as `DELETE`, `DROP`, or `TRUNCATE`.
3. Never run `UPDATE` or `DELETE` without a `WHERE` clause unless the user explicitly intends to affect all rows.
4. Raw secrets, cookies, passkeys, hashed passwords, OTP secrets, API keys, or tokens may appear in tool output. Use them only for the requested operation and avoid repeating them in the final response unless explicitly requested.
4. Raw secrets, cookies, passkeys, hashed passwords, OTP secrets, API keys, or tokens may appear in tool output. Use them only for the requested operation and avoid repeating them in the final response unless explicitly requested. Never add `--show-secrets` to widen a search for a value another surface can already report.
5. Keep output small. Summarize large results instead of dumping them.
## Core Tables

View File

@@ -45,6 +45,11 @@ WRITE_KEYWORD_RE = re.compile(
re.IGNORECASE,
)
SELECT_STATEMENT_RE = re.compile(r"^\s*(select|with|explain)\b", re.IGNORECASE)
# 双引号、反引号与方括号在 SQLite/PostgreSQL 中都是标识符引用,可用于表名识别
_IDENTIFIER_QUOTES = frozenset({'"', "`", "["})
_STATEMENT_WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
# 唯一按 SystemConfigKey 聚合全部实例级凭据的表,托管 API 默认脱敏、直查 SQL 不脱敏
_SECRET_TABLE_NAMES = frozenset({"systemconfig"})
@dataclass(frozen=True, slots=True)
@@ -138,7 +143,7 @@ ACTIONS: dict[str, ActionSpec] = {
),
),
"query": ActionSpec(
"执行一条有结果数量限制的 SQL 查询,可显式启用写入模式。",
"执行一条有结果数量限制的 SQL 查询,可显式启用写入模式与凭据表读取。",
"safe_read",
arguments=(
ArgumentSpec("sql", "string", "单条 SQL 语句,与 file 互斥。"),
@@ -157,10 +162,19 @@ ACTIONS: dict[str, ActionSpec] = {
default=False,
has_default=True,
),
ArgumentSpec(
"show_secrets",
"boolean",
"是否允许引用集中存放实例凭据的 systemconfig 表;"
"只有用户明确要求核对配置值本身时才可设为 true,普通统计不得开启。",
default=False,
has_default=True,
),
),
argument_rules=(
"sql 和 file 必须且只能提供一项。",
"仅允许 SELECT、WITH 和 EXPLAIN;write=true 时才允许写入语句。",
"引用 systemconfig 时必须显式提供 show_secrets=true。",
),
),
"write": ActionSpec(
@@ -169,10 +183,19 @@ ACTIONS: dict[str, ActionSpec] = {
arguments=(
ArgumentSpec("sql", "string", "单条数据写入或结构变更语句,与 file 互斥。"),
ArgumentSpec("file", "string", "本地 SQL 文件路径,与 sql 互斥。"),
ArgumentSpec(
"show_secrets",
"boolean",
"是否允许引用集中存放实例凭据的 systemconfig 表;"
"修复配置行等确需触碰该表的操作才设为 true。",
default=False,
has_default=True,
),
),
argument_rules=(
"sql 和 file 必须且只能提供一项。",
"拒绝在一次调用中执行多条 SQL 语句。",
"引用 systemconfig 时必须显式提供 show_secrets=true。",
),
),
}
@@ -289,9 +312,12 @@ def _sql_literal_end(sql: str, index: int) -> Optional[int]:
return index
def _masked_sql(sql: str) -> Optional[str]:
def _masked_sql(sql: str, *, reveal_identifiers: bool = False) -> Optional[str]:
"""
用等长空白遮蔽字面值、标识符与注释,未闭合结构按无效 SQL 拒绝。
:param sql: 待分析的 SQL 文本
:param reveal_identifiers: 是否只遮蔽标识符定界符以保留表名本体,用于识别引号写法
"""
code = list(sql)
index = 0
@@ -306,13 +332,27 @@ def _masked_sql(sql: str) -> Optional[str]:
if end is None:
return None
if end > index:
code[index:end] = " " * (end - index)
if reveal_identifiers and sql[index] in _IDENTIFIER_QUOTES:
code[index] = " "
code[end - 1] = " "
else:
code[index:end] = " " * (end - index)
index = end
else:
index += 1
return "".join(code)
def _secret_table_names(sql: str) -> set[str]:
"""
返回语句实际引用的凭据配置表名;引号包裹与模式限定写法都会被识别。
"""
code = _masked_sql(sql, reveal_identifiers=True)
if code is None:
return set()
return {word.lower() for word in _STATEMENT_WORD_RE.findall(code)} & _SECRET_TABLE_NAMES
def _contains_multiple_statements(sql: str) -> bool:
"""
判断 SQL 是否包含多语句分隔符。
@@ -329,15 +369,21 @@ def _is_write_statement(sql: str) -> bool:
return code is not None and bool(WRITE_STATEMENT_RE.match(code))
def _is_supported_statement(sql: str, allow_write: bool) -> bool:
def _is_supported_statement(sql: str, allow_write: bool, *, show_secrets: bool = False) -> bool:
"""
判断 SQL 是否在当前权限模式下允许执行。
:param sql: 待校验的 SQL 语句
:param allow_write: 是否允许写操作或结构变更操作
:param show_secrets: 是否允许引用集中存放实例凭据的 systemconfig 表
"""
if _contains_multiple_statements(sql):
return False
code = _masked_sql(sql)
if code is None or not code.strip().strip(";"):
return False
if not show_secrets and _secret_table_names(sql):
return False
if allow_write:
return True
return bool(SELECT_STATEMENT_RE.match(code)) and not WRITE_KEYWORD_RE.search(code)
@@ -401,21 +447,29 @@ def show_schema(table_name: str) -> int:
return 0
def run_query(sql: str, *, limit: int = 100, allow_write: bool = False) -> int:
def run_query(sql: str, *, limit: int = 100, allow_write: bool = False, show_secrets: bool = False) -> int:
"""
执行 SQL 语句并输出 JSON 结果。
:param sql: 要执行的 SQL 语句
:param limit: 查询语句默认追加的最大行数
:param allow_write: 是否允许写操作或结构变更操作
:param show_secrets: 是否允许引用集中存放实例凭据的 systemconfig 表
:return: 进程退出码
"""
normalized_sql = _normalize_sql(sql)
if not normalized_sql:
print("Error: SQL is empty", file=sys.stderr)
return 1
if not _is_supported_statement(normalized_sql, allow_write):
print("Error: write statements require --write", file=sys.stderr)
if not _is_supported_statement(normalized_sql, allow_write, show_secrets=show_secrets):
blocked_tables = sorted(_secret_table_names(normalized_sql))
if blocked_tables:
print(
f"Error: statements referencing {', '.join(blocked_tables)} require --show-secrets",
file=sys.stderr,
)
else:
print("Error: write statements require --write", file=sys.stderr)
return 1
statement_sql = _append_limit(normalized_sql, limit)
@@ -459,10 +513,22 @@ def _build_parser() -> argparse.ArgumentParser:
query_parser.add_argument("--file", dest="sql_file", help="从文件读取 SQL")
query_parser.add_argument("--limit", type=int, default=100, help="SELECT 默认返回行数上限")
query_parser.add_argument("--write", action="store_true", help="允许写入语句")
query_parser.add_argument(
"--show-secrets",
action="store_true",
dest="show_secrets",
help="允许引用集中存放实例凭据的 systemconfig 表",
)
write_parser = subparsers.add_parser("write", help="执行一条写入语句")
write_parser.add_argument("sql", nargs="?", help="SQL 语句")
write_parser.add_argument("--file", dest="sql_file", help="从文件读取 SQL")
write_parser.add_argument(
"--show-secrets",
action="store_true",
dest="show_secrets",
help="允许引用集中存放实例凭据的 systemconfig 表",
)
subparsers.add_parser("tables", help="列出数据库表")
@@ -485,10 +551,15 @@ def main() -> int:
return show_schema(args.table_name)
if args.command == "query":
sql = _read_sql(args.sql, args.sql_file)
return run_query(sql, limit=args.limit, allow_write=args.write)
return run_query(
sql,
limit=args.limit,
allow_write=args.write,
show_secrets=args.show_secrets,
)
if args.command == "write":
sql = _read_sql(args.sql, args.sql_file)
return run_query(sql, limit=0, allow_write=True)
return run_query(sql, limit=0, allow_write=True, show_secrets=args.show_secrets)
parser.print_help()
return 0

View File

@@ -21,7 +21,6 @@
"app/agent/tools/impl/agent_task.py:AgentTaskTool._update_task": 16,
"app/agent/tools/impl/apply_patch.py:parse_patch": 23,
"app/agent/tools/impl/browse_webpage.py:BrowseWebpageTool._do_action": 20,
"app/agent/tools/impl/service.py:_run_database_script": 20,
"app/api/endpoints/media.py:_scrape_impl": 16,
"app/api/endpoints/transfer.py:_execute_manual_transfer": 28,
"app/api/protocol.py:extract_text_and_images": 19,

View File

@@ -270,6 +270,76 @@ def test_system_settings_secret_read_has_enforced_sensitive_policy() -> None:
assert policy.subagent_allowed is False
def test_database_operation_secret_read_has_enforced_sensitive_policy() -> None:
"""
显式读取凭据配置表只能进入宿主强制确认策略,结果不得写入回执摘要。
"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="database_operation",
arguments={
"action": "query",
"arguments": {
"sql": "SELECT value FROM systemconfig",
"show_secrets": True,
},
},
requires_admin=True,
)
assert policy.effect is ActionEffect.SENSITIVE_READ
assert policy.required_role is PrincipalRole.SYSTEM_ADMIN
assert policy.confirmation.value == "required"
assert policy.result_sensitivity is ResultSensitivity.SECRET
assert policy.migration_state is MigrationState.ENFORCED
assert policy.background_allowed is False
assert policy.subagent_allowed is False
@pytest.mark.parametrize(
"arguments",
[
{"sql": "SELECT 1"},
{"sql": "SELECT 1", "show_secrets": False},
{"sql": "SELECT 1", "show_secrets": None},
{"sql": "SELECT 1", "show_secrets": "true"},
],
ids=["plain", "false", "null", "string"],
)
def test_database_operation_without_secret_read_keeps_legacy_shadow(arguments) -> None:
"""
未显式请求凭据表读取时数据库工具维持既有 shadow 观测,非布尔取值也不升级。
"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="database_operation",
arguments={"action": "query", "arguments": arguments},
requires_admin=True,
)
assert policy.migration_state is MigrationState.LEGACY_SHADOW
assert policy.effect is ActionEffect.UNKNOWN
assert policy.result_sensitivity is ResultSensitivity.UNKNOWN
def test_database_operation_secret_write_stays_unclassified() -> None:
"""
凭据表写入保持写入类工具的既有策略,不按敏感读取处理。
"""
policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="database_operation",
arguments={
"action": "write",
"arguments": {
"sql": "UPDATE systemconfig SET value = 'x' WHERE id = 1",
"show_secrets": True,
},
},
requires_admin=True,
)
assert policy.effect is ActionEffect.UNKNOWN
assert policy.migration_state is MigrationState.LEGACY_SHADOW
def test_system_settings_secret_read_query_cannot_bypass_confirmation() -> None:
"""
GET 查询参数不能绕过敏感设置读取的确认策略。

View File

@@ -127,7 +127,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
expected_versions = {
"browser-use": "4",
"command-dispatch": "2",
"database-operation": "15",
"database-operation": "16",
"feedback-issue": "12",
"anysearch": "3",
"moviepilot-api": "65",

View File

@@ -5,6 +5,7 @@
import asyncio
import json
import threading
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -94,13 +95,25 @@ def test_database_mcp_schema_exposes_all_actions_and_sql_sources() -> None:
"write",
]
query_arguments = _branch(schema, "query")["properties"]["arguments"]
assert set(query_arguments["properties"]) == {"sql", "file", "limit", "write"}
assert set(query_arguments["properties"]) == {
"sql",
"file",
"limit",
"write",
"show_secrets",
}
assert query_arguments["properties"]["limit"]["default"] == 100
assert query_arguments["properties"]["limit"]["minimum"] == 1
assert query_arguments["properties"]["show_secrets"]["default"] is False
assert query_arguments["oneOf"] == [
{"required": ["sql"], "not": {"required": ["file"]}},
{"required": ["file"], "not": {"required": ["sql"]}},
]
assert set(_branch(schema, "write")["properties"]["arguments"]["properties"]) == {
"sql",
"file",
"show_secrets",
}
assert _branch(schema, "schema")["properties"]["arguments"]["required"] == [
"table_name"
]
@@ -187,6 +200,28 @@ def test_database_operation_rejects_invalid_nested_arguments_before_script() ->
runner.assert_not_called()
def test_database_operation_forwards_show_secrets_only_when_requested() -> None:
"""
数据库工具应把凭据表开关原样传给固定脚本,默认关闭并拒绝非布尔取值。
"""
tool = DatabaseOperationTool(session_id="session", user_id="api_user")
completed = SimpleNamespace(returncode=0, stdout='{"rows": [], "row_count": 0}', stderr="")
with patch(
"app.agent.tools.impl.service.subprocess.run",
return_value=completed,
) as runner:
asyncio.run(tool.run(action="query", arguments={"sql": "SELECT 1", "show_secrets": True}))
assert "--show-secrets" in runner.call_args.args[0]
asyncio.run(tool.run(action="write", arguments={"sql": "UPDATE subscribe SET name = 'x'"}))
assert "--show-secrets" not in runner.call_args.args[0]
with pytest.raises(ValueError, match="show_secrets"):
asyncio.run(
tool.run(action="query", arguments={"sql": "SELECT 1", "show_secrets": "true"})
)
def test_service_operation_tool_calls_fixed_script_once() -> None:
"""
一次 MCP tools/call 应只执行一次固定脚本并返回其 JSON envelope。

View File

@@ -8,6 +8,9 @@ from pathlib import Path
from types import ModuleType
from typing import Any
import pytest
from sqlalchemy import create_engine, text
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MP_DB_SCRIPT = PROJECT_ROOT / "skills" / "database-operation" / "scripts" / "mp-db.py"
@@ -117,11 +120,19 @@ def test_mp_db_write_command_allows_write_statement(monkeypatch) -> None:
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:
def fake_run_query(
sql: str,
*,
limit: int = 100,
allow_write: bool = False,
show_secrets: bool = False,
) -> int:
"""
记录 write 子命令传入的执行参数。
"""
calls.append({"sql": sql, "limit": limit, "allow_write": allow_write})
calls.append(
{"sql": sql, "limit": limit, "allow_write": allow_write, "show_secrets": show_secrets}
)
return 0
monkeypatch.setattr(module, "run_query", fake_run_query)
@@ -141,5 +152,123 @@ def test_mp_db_write_command_allows_write_statement(monkeypatch) -> None:
"sql": "UPDATE subscribe SET state = 'S' WHERE id = 123",
"limit": 0,
"allow_write": True,
"show_secrets": False,
}
]
@pytest.mark.parametrize(
"sql",
[
"SELECT * FROM systemconfig",
'SELECT * FROM "systemconfig"',
"SELECT * FROM public.systemconfig",
"SELECT * FROM `systemconfig`",
"SELECT * FROM [systemconfig]",
"WITH leak AS (SELECT value FROM systemconfig) SELECT * FROM leak",
"SELECT (SELECT value FROM systemconfig LIMIT 1) AS any_value",
],
)
def test_mp_db_blocks_configuration_tables_without_show_secrets(monkeypatch, sql) -> None:
"""
引用凭据配置表的语句默认拒绝,引号、模式限定、子查询与写入模式都不能绕过。
"""
module = _load_script(MP_DB_SCRIPT, "mp_db_script_secret_blocks")
assert not module._is_supported_statement(sql, False)
assert module._is_supported_statement(sql, False, show_secrets=True)
assert not module._is_supported_statement(sql, True)
@pytest.mark.parametrize(
"sql",
[
"SELECT 'systemconfig' AS name FROM subscribe",
"-- systemconfig\nSELECT 1",
"SELECT 1 /* systemconfig */",
"SELECT name FROM subscribe WHERE name = 'systemconfig'",
"SELECT * FROM userconfig",
"SELECT plugin_id, key FROM plugindata",
],
)
def test_mp_db_configuration_gate_ignores_literals_and_unrelated_tables(
monkeypatch,
sql,
) -> None:
"""
字面值与注释中的表名不触发凭据门禁;门禁只覆盖集中存放实例凭据的 systemconfig,
其它表的普通读取保持原样。
"""
module = _load_script(MP_DB_SCRIPT, "mp_db_script_secret_allows")
assert module._is_supported_statement(sql, False)
def test_mp_db_configuration_table_access_reports_and_requires_explicit_flag(
tmp_path,
monkeypatch,
capsys,
) -> None:
"""
凭据配置表的读取与写入在未显式开启时被拒绝并给出可执行提示,开启后按原值返回。
"""
module = _load_script(MP_DB_SCRIPT, "mp_db_script_secret_exec")
engine = create_engine("sqlite:///" + str(tmp_path / "secret.db"))
module.run_query.__globals__["_build_engine"] = lambda: engine
try:
with engine.begin() as connection:
connection.execute(text("CREATE TABLE systemconfig (id INTEGER, key TEXT, value JSON)"))
connection.execute(
text("INSERT INTO systemconfig VALUES (1, 'TMDB_API_KEY', 'plain-secret')")
)
assert module.run_query("SELECT value FROM systemconfig") == 1
assert "require --show-secrets" in capsys.readouterr().err
assert module.run_query("UPDATE systemconfig SET value = 'x' WHERE id = 1", allow_write=True) == 1
assert "require --show-secrets" in capsys.readouterr().err
assert module.run_query("SELECT value FROM systemconfig", show_secrets=True) == 0
assert json.loads(capsys.readouterr().out)["rows"] == [{"value": "plain-secret"}]
assert (
module.run_query(
"UPDATE systemconfig SET value = 'fixed' WHERE id = 1",
allow_write=True,
show_secrets=True,
)
== 0
)
finally:
engine.dispose()
@pytest.mark.parametrize(
("command", "argv", "expected"),
[
("query", ["query", "SELECT 1", "--show-secrets"], True),
("query", ["query", "SELECT 1"], False),
("write", ["write", "UPDATE systemconfig SET value = 'x' WHERE id = 1", "--show-secrets"], True),
],
)
def test_mp_db_show_secrets_option_reaches_execution(monkeypatch, command, argv, expected) -> None:
"""
两个子命令的 --show-secrets 选项都必须映射到执行参数,缺省保持关闭。
"""
module = _load_script(MP_DB_SCRIPT, "mp_db_script_secret_option")
calls = []
def fake_run_query(
sql: str,
*,
limit: int = 100,
allow_write: bool = False,
show_secrets: bool = False,
) -> int:
"""
记录子命令解析出的凭据表开关。
"""
calls.append(show_secrets)
return 0
monkeypatch.setattr(module, "run_query", fake_run_query)
monkeypatch.setattr(module.sys, "argv", ["mp-db.py", *argv])
assert module.main() == 0
assert calls == [expected]