diff --git a/bot/scripts/install_local_openclaw_plugin.sh b/bot/scripts/install_local_openclaw_plugin.sh index 6fab46ac6..b8f7a76ec 100755 --- a/bot/scripts/install_local_openclaw_plugin.sh +++ b/bot/scripts/install_local_openclaw_plugin.sh @@ -44,7 +44,7 @@ if [[ "$REBUILD" == "false" ]]; then # 安装依赖 echo "安装依赖..." cd "$OPENCLAW_PLUGIN_DIR" - npm install --include=dev + npm install --ignore-scripts --include=dev fi # 编译 TypeScript diff --git a/docs/en/guides/10-prompt-guide.md b/docs/en/guides/10-prompt-guide.md index 5649a1464..238b6ee98 100644 --- a/docs/en/guides/10-prompt-guide.md +++ b/docs/en/guides/10-prompt-guide.md @@ -449,10 +449,17 @@ Applicable when: Available configuration: +- `prompts.enable_custom_templates` - `prompts.templates_dir` - environment variable `OPENVIKING_PROMPT_TEMPLATES_DIR` -Load priority: +Secure default: + +- custom prompt templates are disabled by default +- OpenViking only loads bundled templates unless a trusted operator sets `prompts.enable_custom_templates: true` +- `OPENVIKING_PROMPT_TEMPLATES_DIR` is ignored unless that setting is enabled + +Load priority after opt-in: 1. Explicitly provided template directory 2. Environment variable `OPENVIKING_PROMPT_TEMPLATES_DIR` @@ -485,6 +492,7 @@ Example configuration: ```json { "prompts": { + "enable_custom_templates": true, "templates_dir": "/path/to/custom-prompts" } } diff --git a/docs/zh/guides/10-prompt-guide.md b/docs/zh/guides/10-prompt-guide.md index 1e1351b8e..fd8e5a140 100644 --- a/docs/zh/guides/10-prompt-guide.md +++ b/docs/zh/guides/10-prompt-guide.md @@ -449,10 +449,17 @@ OpenViking 支持两种主要的自定义方式: 可用配置: +- `prompts.enable_custom_templates` - `prompts.templates_dir` - 环境变量 `OPENVIKING_PROMPT_TEMPLATES_DIR` -加载优先级: +安全默认值: + +- 自定义 prompt 模板默认禁用 +- 只有受信任运维显式设置 `prompts.enable_custom_templates: true` 时,OpenViking 才会加载外部模板目录 +- 在未开启该设置时,`OPENVIKING_PROMPT_TEMPLATES_DIR` 会被忽略 + +开启后的加载优先级: 1. 显式传入的模板目录 2. 环境变量 `OPENVIKING_PROMPT_TEMPLATES_DIR` @@ -485,6 +492,7 @@ custom-prompts/ ```json { "prompts": { + "enable_custom_templates": true, "templates_dir": "/path/to/custom-prompts" } } diff --git a/examples/openclaw-plugin/install.sh b/examples/openclaw-plugin/install.sh index f93378fa6..ae3528605 100755 --- a/examples/openclaw-plugin/install.sh +++ b/examples/openclaw-plugin/install.sh @@ -1842,7 +1842,7 @@ download_plugin() { # npm install info "$(tr "Installing plugin npm dependencies..." "正在安装插件 npm 依赖...")" - local npm_args="--no-audit --no-fund" + local npm_args="--ignore-scripts --no-audit --no-fund" if [[ "$RESOLVED_NPM_OMIT_DEV" == "true" ]]; then npm_args="--omit=dev $npm_args" fi diff --git a/examples/openclaw-plugin/setup-helper/install.js b/examples/openclaw-plugin/setup-helper/install.js index 7c9bc739a..010fba7d0 100755 --- a/examples/openclaw-plugin/setup-helper/install.js +++ b/examples/openclaw-plugin/setup-helper/install.js @@ -1903,8 +1903,8 @@ async function downloadPlugin(destDir) { // npm install info(tr("Installing plugin npm dependencies...", "正在安装插件 npm 依赖...")); const npmArgs = resolvedNpmOmitDev - ? ["install", "--omit=dev", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY] - : ["install", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY]; + ? ["install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY] + : ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY]; await run("npm", npmArgs, { cwd: destDir, silent: false }); info(tr(`Plugin deployed: ${PLUGIN_DEST}`, `插件部署完成: ${PLUGIN_DEST}`)); } @@ -1927,8 +1927,8 @@ async function deployLocalPlugin(localPluginDir, destDir) { async function installPluginDependencies(destDir) { info(tr("Installing plugin npm dependencies...", "正在安装插件 npm 依赖...")); const npmArgs = resolvedNpmOmitDev - ? ["install", "--omit=dev", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY] - : ["install", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY]; + ? ["install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY] + : ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY]; await run("npm", npmArgs, { cwd: destDir, silent: false }); return info(tr(`Plugin prepared: ${destDir}`, `插件已准备: ${destDir}`)); } diff --git a/openviking/prompts/manager.py b/openviking/prompts/manager.py index e7a6df223..f07b58340 100644 --- a/openviking/prompts/manager.py +++ b/openviking/prompts/manager.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional import yaml -from jinja2 import Template +from jinja2.sandbox import SandboxedEnvironment from pydantic import BaseModel, Field from openviking_cli.utils.config import ( @@ -70,7 +70,10 @@ def __init__( Args: templates_dir: Directory containing YAML templates. - If None, uses bundled templates. + If provided explicitly, this trusted in-process override + takes precedence over config-based custom template loading. + If None, PromptManager uses bundled templates unless + prompts.enable_custom_templates is enabled. enable_caching: Enable prompt template caching """ self.templates_dir = self._resolve_templates_dir(templates_dir) @@ -84,15 +87,18 @@ def _resolve_templates_dir(cls, templates_dir: Optional[Path]) -> Path: if templates_dir is not None: return Path(templates_dir) - env_dir = os.environ.get(OPENVIKING_PROMPT_TEMPLATES_DIR_ENV) - if env_dir: - return Path(env_dir).expanduser() - try: config = get_openviking_config() except FileNotFoundError: return cls._get_bundled_templates_dir() + if not config.prompts.enable_custom_templates: + return cls._get_bundled_templates_dir() + + env_dir = os.environ.get(OPENVIKING_PROMPT_TEMPLATES_DIR_ENV) + if env_dir: + return Path(env_dir).expanduser() + config_dir = config.prompts.templates_dir.strip() if config_dir: return Path(config_dir).expanduser() @@ -200,8 +206,8 @@ def render( ): variables[var_def.name] = variables[var_def.name][: var_def.max_length] - # Render template with Jinja2 - jinja_template = Template(template.template) + # Render template with sandboxed Jinja2 + jinja_template = SandboxedEnvironment(autoescape=False).from_string(template.template) return jinja_template.render(**variables) def _validate_variables(self, template: PromptTemplate, variables: Dict[str, Any]) -> None: diff --git a/openviking_cli/utils/config/prompts_config.py b/openviking_cli/utils/config/prompts_config.py index 340c96529..0db24bc66 100644 --- a/openviking_cli/utils/config/prompts_config.py +++ b/openviking_cli/utils/config/prompts_config.py @@ -8,11 +8,19 @@ class PromptsConfig(BaseModel): """Prompt template configuration for OpenViking.""" + enable_custom_templates: bool = Field( + default=False, + description=( + "Enable loading prompt templates from operator-configured external directories. " + "Disabled by default so PromptManager only uses bundled templates unless a " + "trusted operator explicitly opts in." + ), + ) templates_dir: str = Field( default="", description=( - "Custom prompt templates directory. If set, PromptManager loads prompt " - "templates from this directory instead of the bundled templates." + "Custom prompt templates directory. Only used when " + "prompts.enable_custom_templates is true." ), ) diff --git a/tests/misc/test_prompt_manager_security.py b/tests/misc/test_prompt_manager_security.py new file mode 100644 index 000000000..f166b7faa --- /dev/null +++ b/tests/misc/test_prompt_manager_security.py @@ -0,0 +1,52 @@ +from pathlib import Path + +import pytest +from jinja2.exceptions import SecurityError + +from openviking.prompts.manager import PromptManager + + +def _write_template(base: Path, body: str) -> None: + target = base / "memory" + target.mkdir(parents=True, exist_ok=True) + indented_body = "\n".join(f" {line}" for line in body.splitlines()) or " " + (target / "profile.yaml").write_text( + """ +metadata: + id: memory.profile + name: Profile + description: Test template + version: "1.0" + language: en + category: memory +variables: + - name: user_name + type: string + description: user name + required: false +template: | +""".lstrip() + + indented_body + + "\n", + encoding="utf-8", + ) + + +def test_prompt_manager_renders_safe_template(tmp_path: Path) -> None: + _write_template(tmp_path, "Hello {{ user_name }}") + manager = PromptManager(templates_dir=tmp_path, enable_caching=False) + + rendered = manager.render("memory.profile", {"user_name": "alice"}) + + assert rendered.strip() == "Hello alice" + + +def test_prompt_manager_blocks_unsafe_jinja_attribute_access(tmp_path: Path) -> None: + _write_template( + tmp_path, + '{{ cycler.__init__.__globals__.os.system("echo should_not_run") }}', + ) + manager = PromptManager(templates_dir=tmp_path, enable_caching=False) + + with pytest.raises(SecurityError): + manager.render("memory.profile") diff --git a/tests/test_prompt_manager.py b/tests/test_prompt_manager.py index 26d695987..7d2978463 100644 --- a/tests/test_prompt_manager.py +++ b/tests/test_prompt_manager.py @@ -33,7 +33,12 @@ def _write_template(templates_dir: Path, content: str) -> None: ) -def _write_config(config_path: Path, templates_dir: Path) -> None: +def _write_config( + config_path: Path, + templates_dir: Path, + *, + enable_custom_templates: bool = False, +) -> None: config_path.write_text( json.dumps( { @@ -46,10 +51,11 @@ def _write_config(config_path: Path, templates_dir: Path) -> None: "dense": { "provider": "openai", "model": "text-embedding-3-small", - "api_key": "test-key", + "api_key": "***", } }, "prompts": { + "enable_custom_templates": enable_custom_templates, "templates_dir": str(templates_dir), }, } @@ -69,7 +75,7 @@ def test_prompt_manager_prefers_environment_templates_dir(tmp_path, monkeypatch) _write_template(env_dir, "env-template") _write_template(config_dir, "config-template") - _write_config(config_path, config_dir) + _write_config(config_path, config_dir, enable_custom_templates=True) OpenVikingConfigSingleton.reset_instance() monkeypatch.setenv(OPENVIKING_CONFIG_ENV, str(config_path)) @@ -86,7 +92,7 @@ def test_prompt_manager_uses_ov_conf_templates_dir_when_env_is_unset(tmp_path, m config_path = tmp_path / "ov.conf" _write_template(config_dir, "config-template") - _write_config(config_path, config_dir) + _write_config(config_path, config_dir, enable_custom_templates=True) OpenVikingConfigSingleton.reset_instance() monkeypatch.setenv(OPENVIKING_CONFIG_ENV, str(config_path)) @@ -98,14 +104,58 @@ def test_prompt_manager_uses_ov_conf_templates_dir_when_env_is_unset(tmp_path, m assert manager.render("memory.profile") == "config-template" -def test_prompt_manager_falls_back_to_bundled_templates_dir(monkeypatch): +def test_prompt_manager_falls_back_to_bundled_templates_dir(tmp_path, monkeypatch): + missing_config = tmp_path / "missing-ov.conf" + + OpenVikingConfigSingleton.reset_instance() + monkeypatch.setenv(OPENVIKING_CONFIG_ENV, str(missing_config)) + monkeypatch.delenv(OPENVIKING_PROMPT_TEMPLATES_DIR_ENV, raising=False) + + manager = PromptManager(enable_caching=False) + + assert manager.templates_dir == PromptManager._get_bundled_templates_dir() + + +def test_prompt_manager_ignores_custom_templates_from_config_when_not_enabled( + tmp_path, monkeypatch +): + custom_dir = tmp_path / "custom-prompts" + config_path = tmp_path / "ov.conf" + + _write_template(custom_dir, "custom-profile-template") + _write_config(config_path, custom_dir, enable_custom_templates=False) + OpenVikingConfigSingleton.reset_instance() - monkeypatch.delenv(OPENVIKING_CONFIG_ENV, raising=False) + monkeypatch.setenv(OPENVIKING_CONFIG_ENV, str(config_path)) monkeypatch.delenv(OPENVIKING_PROMPT_TEMPLATES_DIR_ENV, raising=False) manager = PromptManager(enable_caching=False) assert manager.templates_dir == PromptManager._get_bundled_templates_dir() + bundled_template = manager.load_template("vision.image_understanding") + assert bundled_template.metadata.id == "vision.image_understanding" + assert ( + manager._resolve_template_path("memory.profile") != custom_dir / "memory" / "profile.yaml" + ) + + +def test_prompt_manager_ignores_environment_templates_dir_when_not_enabled(tmp_path, monkeypatch): + env_dir = tmp_path / "env-prompts" + config_dir = tmp_path / "config-prompts" + config_path = tmp_path / "ov.conf" + + _write_template(env_dir, "env-template") + _write_template(config_dir, "config-template") + _write_config(config_path, config_dir, enable_custom_templates=False) + + OpenVikingConfigSingleton.reset_instance() + monkeypatch.setenv(OPENVIKING_CONFIG_ENV, str(config_path)) + monkeypatch.setenv(OPENVIKING_PROMPT_TEMPLATES_DIR_ENV, str(env_dir)) + + manager = PromptManager(enable_caching=False) + + assert manager.templates_dir == PromptManager._get_bundled_templates_dir() + assert manager._resolve_template_path("memory.profile") != env_dir / "memory" / "profile.yaml" def test_prompt_manager_falls_back_to_bundled_template_when_custom_dir_is_partial( @@ -115,7 +165,7 @@ def test_prompt_manager_falls_back_to_bundled_template_when_custom_dir_is_partia config_path = tmp_path / "ov.conf" _write_template(custom_dir, "custom-profile-template") - _write_config(config_path, custom_dir) + _write_config(config_path, custom_dir, enable_custom_templates=True) OpenVikingConfigSingleton.reset_instance() monkeypatch.setenv(OPENVIKING_CONFIG_ENV, str(config_path))