Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bot/scripts/install_local_openclaw_plugin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docs/en/guides/10-prompt-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -485,6 +492,7 @@ Example configuration:
```json
{
"prompts": {
"enable_custom_templates": true,
"templates_dir": "/path/to/custom-prompts"
}
}
Expand Down
10 changes: 9 additions & 1 deletion docs/zh/guides/10-prompt-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -485,6 +492,7 @@ custom-prompts/
```json
{
"prompts": {
"enable_custom_templates": true,
"templates_dir": "/path/to/custom-prompts"
}
}
Expand Down
2 changes: 1 addition & 1 deletion examples/openclaw-plugin/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions examples/openclaw-plugin/setup-helper/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`));
}
Expand All @@ -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}`));
}
Expand Down
22 changes: 14 additions & 8 deletions openviking/prompts/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions openviking_cli/utils/config/prompts_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
)

Expand Down
52 changes: 52 additions & 0 deletions tests/misc/test_prompt_manager_security.py
Original file line number Diff line number Diff line change
@@ -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")
64 changes: 57 additions & 7 deletions tests/test_prompt_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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),
},
}
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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(
Expand All @@ -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))
Expand Down
Loading