blob: 4b3f4180a9c0526be055b3a19cf609e9b78cc59a [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
#
from __future__ import annotations
from collections.abc import Mapping
import json
import os
from pathlib import Path
import re
from threading import RLock
from typing import Any
_DEFAULT_SERVER_NAME = "iotdb"
_SHELL_DEFAULT_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}$")
_SENSITIVE_KEY_RE = re.compile(r"(PASSWORD|SECRET|TOKEN|API_KEY|ACCESS_KEY)", re.IGNORECASE)
_WITHHELD_ENV_KEYS = frozenset({"TIMESEEK_IOTDB_TARGETS_JSON"})
_SESSION_POLICY_LOCK = RLock()
_SESSION_POLICY: dict[str, str] = {}
_ENFORCEMENT_POLICY_KEYS = frozenset(
{
"TIMESEEK_MCP_PERMISSION_ENFORCEMENT",
"IOTDB_STRICT_PERMISSION_ENFORCEMENT",
}
)
_POLICY_KEYS = frozenset(
{
"IOTDB_ENABLE_METADATA_QUERY",
"IOTDB_METADATA_ALLOWED_USERS",
"IOTDB_ENABLE_DATABASE_DDL",
"IOTDB_DATABASE_DDL_ALLOWED_USERS",
"IOTDB_REQUIRE_DROP_CONFIRM",
"IOTDB_ENABLE_TABLE_DDL",
"IOTDB_TABLE_DDL_ALLOWED_USERS",
"IOTDB_REQUIRE_TABLE_DROP_CONFIRM",
"IOTDB_ENABLE_TIMESERIES_DDL",
"IOTDB_TIMESERIES_DDL_ALLOWED_USERS",
"IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM",
"IOTDB_ENABLE_TTL_SQL",
"IOTDB_TTL_ALLOWED_USERS",
"IOTDB_REQUIRE_TTL_UNSET_CONFIRM",
"IOTDB_ENABLE_WRITE_DML",
"IOTDB_WRITE_ALLOWED_USERS",
"IOTDB_REQUIRE_DELETE_CONFIRM",
"IOTDB_ENABLE_SQL_DRIVER",
"IOTDB_SQL_DRIVER_ALLOWED_USERS",
"IOTDB_SQL_DRIVER_MODE",
"IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM",
"IOTDB_SQL_DRIVER_EXTRA_READONLY_PREFIXES",
"IOTDB_SQL_DRIVER_EXTRA_DDL_PREFIXES",
"IOTDB_SQL_DRIVER_EXTRA_FULL_PREFIXES",
"IOTDB_ENABLE_MODEL_MANAGEMENT",
"IOTDB_MODEL_ALLOWED_USERS",
"IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM",
"TIMESEEK_MCP_PERMISSION_ENFORCEMENT",
"IOTDB_STRICT_PERMISSION_ENFORCEMENT",
}
)
_FULL_PERMISSION_DEFAULTS = {
"IOTDB_ENABLE_METADATA_QUERY": "true",
"IOTDB_METADATA_ALLOWED_USERS": "*",
"IOTDB_ENABLE_DATABASE_DDL": "true",
"IOTDB_DATABASE_DDL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_DROP_CONFIRM": "true",
"IOTDB_ENABLE_TABLE_DDL": "true",
"IOTDB_TABLE_DDL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_TABLE_DROP_CONFIRM": "true",
"IOTDB_ENABLE_TIMESERIES_DDL": "true",
"IOTDB_TIMESERIES_DDL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM": "true",
"IOTDB_ENABLE_TTL_SQL": "true",
"IOTDB_TTL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_TTL_UNSET_CONFIRM": "true",
"IOTDB_ENABLE_WRITE_DML": "true",
"IOTDB_WRITE_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_DELETE_CONFIRM": "true",
"IOTDB_ENABLE_SQL_DRIVER": "true",
"IOTDB_SQL_DRIVER_ALLOWED_USERS": "*",
"IOTDB_SQL_DRIVER_MODE": "full",
"IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM": "true",
"IOTDB_ENABLE_MODEL_MANAGEMENT": "true",
"IOTDB_MODEL_ALLOWED_USERS": "*",
"IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM": "true",
}
_ENFORCEMENT_DEFAULTS = {
"TIMESEEK_MCP_PERMISSION_ENFORCEMENT": "advisory",
"IOTDB_STRICT_PERMISSION_ENFORCEMENT": "false",
}
_PRESET_POLICIES = {
"full": dict(_FULL_PERMISSION_DEFAULTS),
"ddl": {
**_FULL_PERMISSION_DEFAULTS,
"IOTDB_SQL_DRIVER_MODE": "ddl",
"IOTDB_ENABLE_WRITE_DML": "false",
},
"readonly": {
**_FULL_PERMISSION_DEFAULTS,
"IOTDB_ENABLE_DATABASE_DDL": "false",
"IOTDB_ENABLE_TABLE_DDL": "false",
"IOTDB_ENABLE_TIMESERIES_DDL": "false",
"IOTDB_ENABLE_TTL_SQL": "false",
"IOTDB_ENABLE_WRITE_DML": "false",
"IOTDB_SQL_DRIVER_MODE": "readonly",
},
}
def _candidate_mcp_config_paths() -> list[Path]:
candidates: list[Path] = []
explicit = os.getenv("TIMESEEK_MCP_CONFIG_PATH")
if explicit:
candidates.append(Path(explicit).expanduser())
timeseek_root = os.getenv("TIMESEEK_ROOT")
if timeseek_root:
root = Path(timeseek_root).expanduser()
candidates.extend(
[
root / ".mcp.json",
root / "timeseek-codex-multi-plugin" / ".mcp.json",
root / "timeseek-codex-single-plugin" / ".mcp.json",
]
)
repo_root = os.getenv("REPO_ROOT") or os.getenv("TIMESEEK_REPO_ROOT")
if repo_root:
root = Path(repo_root).expanduser()
candidates.extend(
[
root / ".mcp.json",
root / "timeseek_codex" / "timeseek-codex-multi-plugin" / ".mcp.json",
root / "timeseek_codex" / "timeseek-codex-single-plugin" / ".mcp.json",
root / "timeseek_cc" / ".mcp.json",
]
)
cwd = Path.cwd()
candidates.extend(
[
cwd / ".mcp.json",
cwd / "timeseek_codex" / "timeseek-codex-multi-plugin" / ".mcp.json",
cwd / "timeseek_codex" / "timeseek-codex-single-plugin" / ".mcp.json",
cwd / "timeseek_cc" / ".mcp.json",
]
)
deduped: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key not in seen:
seen.add(key)
deduped.append(candidate)
return deduped
def _expand_config_value(value: Any) -> str:
text = str(value)
match = _SHELL_DEFAULT_RE.fullmatch(text)
if not match:
return text
name, default = match.groups()
current = os.getenv(name)
return current if current not in (None, "") else default
def _load_mcp_env() -> tuple[dict[str, str], str]:
server_name = os.getenv("TIMESEEK_MCP_SERVER_NAME", _DEFAULT_SERVER_NAME)
for path in _candidate_mcp_config_paths():
if not path.exists():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
server = data.get("mcpServers", {}).get(server_name, {})
env = server.get("env", {})
if isinstance(env, dict):
return {str(key): _expand_config_value(value) for key, value in env.items()}, str(path)
return {}, ""
def dynamic_getenv(name: str, default: str | None = None) -> str | None:
with _SESSION_POLICY_LOCK:
value = _SESSION_POLICY.get(name)
if value not in (None, ""):
return value
env, _ = _load_mcp_env()
value = env.get(name)
if value not in (None, ""):
return value
value = os.getenv(name)
if value not in (None, ""):
return value
return _FULL_PERMISSION_DEFAULTS.get(
name,
_ENFORCEMENT_DEFAULTS.get(name, default),
)
def dynamic_env_bool(name: str, default: bool) -> bool:
value = dynamic_getenv(name)
if value is None:
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off", ""}:
return False
return default
def permission_enforcement_mode() -> str:
if dynamic_env_bool("IOTDB_STRICT_PERMISSION_ENFORCEMENT", False):
return "strict"
mode = (dynamic_getenv("TIMESEEK_MCP_PERMISSION_ENFORCEMENT", "advisory") or "advisory").strip().lower()
if mode not in {"advisory", "strict"}:
return "advisory"
return mode
def strict_permission_enforcement() -> bool:
return permission_enforcement_mode() == "strict"
def _redact_env(env: dict[str, str]) -> dict[str, str]:
return {
key: "***"
if key in _WITHHELD_ENV_KEYS or _SENSITIVE_KEY_RE.search(key)
else value
for key, value in env.items()
}
def _session_policy_copy() -> dict[str, str]:
with _SESSION_POLICY_LOCK:
return dict(_SESSION_POLICY)
def _coerce_policy_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _validate_policy_keys(policy: Mapping[str, Any]) -> None:
unknown = sorted(str(key) for key in policy if str(key) not in _POLICY_KEYS)
if unknown:
raise ValueError(
"Unsupported session policy key(s): "
+ ", ".join(unknown)
+ ". Use get_iotdb_session_policy to inspect supported keys."
)
def session_policy_for_preset(preset: str) -> dict[str, str]:
normalized = preset.strip().lower()
try:
return dict(_PRESET_POLICIES[normalized])
except KeyError as exc:
raise ValueError("Unsupported policy preset. Expected one of: full, ddl, readonly.") from exc
def supported_policy_keys() -> list[str]:
return sorted(_POLICY_KEYS)
def set_session_policy(
policy: Mapping[str, Any] | None = None,
*,
preset: str | None = None,
replace: bool = False,
) -> dict[str, Any]:
updates: dict[str, str] = {}
if preset:
updates.update(session_policy_for_preset(preset))
if policy:
_validate_policy_keys(policy)
updates.update({str(key): _coerce_policy_value(value) for key, value in policy.items()})
with _SESSION_POLICY_LOCK:
preserved_enforcement = (
{
key: value
for key, value in _SESSION_POLICY.items()
if key in _ENFORCEMENT_POLICY_KEYS
}
if replace and preset
else {}
)
if replace:
_SESSION_POLICY.clear()
_SESSION_POLICY.update(preserved_enforcement)
_SESSION_POLICY.update(updates)
return dynamic_policy_snapshot()
def reset_session_policy(keys: list[str] | None = None) -> dict[str, Any]:
if keys is not None:
_validate_policy_keys({key: "" for key in keys})
with _SESSION_POLICY_LOCK:
if keys is None:
_SESSION_POLICY.clear()
else:
for key in keys:
_SESSION_POLICY.pop(key, None)
return dynamic_policy_snapshot()
def dynamic_policy_snapshot() -> dict[str, Any]:
env, path = _load_mcp_env()
session_policy = _session_policy_copy()
effective: dict[str, str] = {}
for key in supported_policy_keys():
value = dynamic_getenv(key, "")
if value not in (None, ""):
effective[key] = value
return {
"mcp_config_path": path,
"session_policy": _redact_env(session_policy),
"mcp_env": _redact_env(env),
"defaults": {**_FULL_PERMISSION_DEFAULTS, **_ENFORCEMENT_DEFAULTS},
"effective_policy": _redact_env(effective),
"supported_keys": supported_policy_keys(),
"presets": sorted(_PRESET_POLICIES),
}