mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
2f7f70825b
* feat: wire prompt templates into session startup with full creation-path support
Prompt templates (prompt_templates table) now have runtime effect:
- is_default=true templates auto-apply as system message content,
concatenated in name order before user instructions
- Per-workstream template selection via --template CLI flag, template
field on POST /v1/api/workstreams/new, console creation modal dropdown,
scheduled task config, and channel adapter config
- {{model}}, {{ws_id}}, {{node_id}} variable substitution via single-pass
regex (prevents cross-variable injection)
- /template slash command for runtime switching, persisted across resume
- set_template() public API on ChatSession
Security hardening:
- MCP sync resets is_default=False on content update (prevents compromised
server from injecting defaults)
- 32KB content cap on template create/update + defensive truncation
- Template existence validation returns 400 before workstream creation
- Single-pass regex eliminates cross-variable expansion
Template field plumbed through all creation paths: CLI, server API, MQ
protocol/bridge, console backend, scheduler dispatch, channel router,
MQ client. Migration 010 adds template column to scheduled_tasks.
Frontend: console workstream modal template dropdown, scheduler
create/edit template field, governance template UI variables auto-detected
from content (read-only display replaces editable input). Focus trap and
Enter-key accessibility fixes in workstream modal.
Docs: governance.md template runtime section, api-reference.md template
field, governance + MCP architecture diagrams updated.
Python + TypeScript SDKs, Pydantic schemas all updated. 29 new tests.
* fix: address PR #47 review feedback
- Defer template validation until after resume_ws — a bad template name
no longer 400s when resume would have ignored it anyway
- Add template field to OpenAPI JSON specs (openapi-server.json,
openapi-console.json) for SDK/docs consistency
- Validate template existence in schedule create and update endpoints —
reject unknown template names with 400 instead of allowing schedules
that would silently fail at dispatch time
273 lines
9.3 KiB
Python
273 lines
9.3 KiB
Python
"""Tests for MCP prompt → governance template sync and readonly API guards."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.mcp_client import MCPClientManager
|
|
|
|
|
|
@pytest.fixture()
|
|
def mgr() -> MCPClientManager:
|
|
"""Create an MCPClientManager with no real servers (no start())."""
|
|
return MCPClientManager({})
|
|
|
|
|
|
def _make_storage() -> MagicMock:
|
|
"""Create a mock storage backend with prompt template methods."""
|
|
storage = MagicMock()
|
|
storage.get_prompt_template_by_name.return_value = None
|
|
storage.list_prompt_templates_by_origin.return_value = []
|
|
storage.create_prompt_template.return_value = None
|
|
storage.update_prompt_template.return_value = True
|
|
storage.delete_prompt_template.return_value = True
|
|
return storage
|
|
|
|
|
|
class TestSyncPromptsToStorage:
|
|
def test_sync_no_storage(self, mgr: MCPClientManager) -> None:
|
|
"""Without storage set, sync returns empty stats."""
|
|
result = mgr.sync_prompts_to_storage()
|
|
assert result == {"added": [], "removed": [], "skipped": []}
|
|
|
|
def test_sync_creates_mcp_templates(self, mgr: MCPClientManager) -> None:
|
|
"""New MCP prompts are created as templates."""
|
|
storage = _make_storage()
|
|
mgr.set_storage(storage)
|
|
|
|
# Populate internal prompts list directly
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__test__greeting",
|
|
"original_name": "greeting",
|
|
"server": "test",
|
|
"description": "Say hello",
|
|
"arguments": [
|
|
{"name": "name", "description": "Who to greet", "required": True},
|
|
],
|
|
},
|
|
]
|
|
|
|
result = mgr.sync_prompts_to_storage()
|
|
|
|
assert result["added"] == ["mcp__test__greeting"]
|
|
assert result["removed"] == []
|
|
assert result["skipped"] == []
|
|
storage.create_prompt_template.assert_called_once()
|
|
call_kwargs = storage.create_prompt_template.call_args
|
|
assert call_kwargs[1]["name"] == "mcp__test__greeting"
|
|
assert call_kwargs[1]["origin"] == "mcp"
|
|
assert call_kwargs[1]["mcp_server"] == "test"
|
|
assert call_kwargs[1]["readonly"] is True
|
|
assert call_kwargs[1]["category"] == "mcp"
|
|
assert '"name"' in call_kwargs[1]["variables"]
|
|
|
|
def test_sync_skips_manual_overrides(self, mgr: MCPClientManager) -> None:
|
|
"""A manual template with the same name is not overwritten."""
|
|
storage = _make_storage()
|
|
storage.get_prompt_template_by_name.return_value = {
|
|
"template_id": "existing-id",
|
|
"name": "mcp__test__greeting",
|
|
"origin": "manual",
|
|
"readonly": False,
|
|
}
|
|
mgr.set_storage(storage)
|
|
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__test__greeting",
|
|
"original_name": "greeting",
|
|
"server": "test",
|
|
"description": "Say hello",
|
|
"arguments": [],
|
|
},
|
|
]
|
|
|
|
result = mgr.sync_prompts_to_storage()
|
|
|
|
assert result["skipped"] == ["mcp__test__greeting"]
|
|
assert result["added"] == []
|
|
storage.create_prompt_template.assert_not_called()
|
|
storage.update_prompt_template.assert_not_called()
|
|
|
|
def test_sync_updates_existing_mcp_template(self, mgr: MCPClientManager) -> None:
|
|
"""An existing MCP template gets its content/variables updated."""
|
|
storage = _make_storage()
|
|
storage.get_prompt_template_by_name.return_value = {
|
|
"template_id": "existing-id",
|
|
"name": "mcp__test__greeting",
|
|
"origin": "mcp",
|
|
"mcp_server": "test",
|
|
"readonly": True,
|
|
}
|
|
mgr.set_storage(storage)
|
|
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__test__greeting",
|
|
"original_name": "greeting",
|
|
"server": "test",
|
|
"description": "Updated description",
|
|
"arguments": [
|
|
{"name": "user", "description": "The user", "required": False},
|
|
],
|
|
},
|
|
]
|
|
|
|
result = mgr.sync_prompts_to_storage()
|
|
|
|
assert result["added"] == []
|
|
assert result["skipped"] == []
|
|
storage.create_prompt_template.assert_not_called()
|
|
storage.update_prompt_template.assert_called_once()
|
|
call_args = storage.update_prompt_template.call_args
|
|
assert call_args[0][0] == "existing-id"
|
|
assert "Updated description" in call_args[1]["content"]
|
|
assert "user" in call_args[1]["variables"]
|
|
# Security: is_default must be reset to prevent compromised MCP server
|
|
# from injecting content into a previously admin-promoted default
|
|
assert call_args[1]["is_default"] is False
|
|
|
|
def test_sync_resets_is_default_on_promoted_template(self, mgr: MCPClientManager) -> None:
|
|
"""An MCP template promoted to default by admin gets is_default reset on sync."""
|
|
storage = _make_storage()
|
|
storage.get_prompt_template_by_name.return_value = {
|
|
"template_id": "promoted-id",
|
|
"name": "mcp__test__greeting",
|
|
"origin": "mcp",
|
|
"mcp_server": "test",
|
|
"readonly": True,
|
|
"is_default": True, # admin toggled this
|
|
}
|
|
mgr.set_storage(storage)
|
|
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__test__greeting",
|
|
"original_name": "greeting",
|
|
"server": "test",
|
|
"description": "Potentially compromised content",
|
|
"arguments": [],
|
|
},
|
|
]
|
|
|
|
mgr.sync_prompts_to_storage()
|
|
|
|
call_args = storage.update_prompt_template.call_args
|
|
assert call_args[1]["is_default"] is False
|
|
|
|
def test_sync_removes_deleted_prompts(self, mgr: MCPClientManager) -> None:
|
|
"""MCP templates in storage with no matching prompt are deleted."""
|
|
storage = _make_storage()
|
|
storage.list_prompt_templates_by_origin.return_value = [
|
|
{
|
|
"template_id": "old-id",
|
|
"name": "mcp__test__old_prompt",
|
|
"origin": "mcp",
|
|
"mcp_server": "test",
|
|
},
|
|
]
|
|
mgr.set_storage(storage)
|
|
mgr._prompts = [] # No prompts at all
|
|
|
|
result = mgr.sync_prompts_to_storage()
|
|
|
|
assert result["removed"] == ["mcp__test__old_prompt"]
|
|
storage.delete_prompt_template.assert_called_once_with("old-id")
|
|
|
|
|
|
class TestSetStorageAutoSync:
|
|
"""set_storage() triggers an immediate sync when servers are already connected."""
|
|
|
|
def test_set_storage_syncs_when_connected(self, mgr) -> None:
|
|
storage = _make_storage()
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__srv__p1",
|
|
"original_name": "p1",
|
|
"server": "srv",
|
|
"description": "A prompt",
|
|
"arguments": [],
|
|
}
|
|
]
|
|
mgr._connected.set()
|
|
|
|
mgr.set_storage(storage)
|
|
|
|
# Should have called create_prompt_template for the discovered prompt
|
|
storage.create_prompt_template.assert_called_once()
|
|
call_kwargs = storage.create_prompt_template.call_args
|
|
assert call_kwargs[1]["name"] == "mcp__srv__p1"
|
|
assert call_kwargs[1]["origin"] == "mcp"
|
|
|
|
def test_set_storage_no_sync_when_not_connected(self, mgr) -> None:
|
|
storage = _make_storage()
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__srv__p1",
|
|
"original_name": "p1",
|
|
"server": "srv",
|
|
"description": "A prompt",
|
|
"arguments": [],
|
|
}
|
|
]
|
|
# _connected is NOT set
|
|
|
|
mgr.set_storage(storage)
|
|
|
|
# Should not have synced
|
|
storage.create_prompt_template.assert_not_called()
|
|
|
|
|
|
class TestReadonlyAPIGuards:
|
|
"""Test that the console server API guards reject edits to readonly templates."""
|
|
|
|
@pytest.fixture()
|
|
def db(self, tmp_path):
|
|
"""Create a fresh SQLite backend for each test."""
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
def test_readonly_guard_update(self, db) -> None:
|
|
"""Readonly templates cannot be updated via storage guard logic."""
|
|
db.create_prompt_template(
|
|
"t1",
|
|
"mcp__srv__prompt",
|
|
"mcp",
|
|
"content",
|
|
variables="[]",
|
|
is_default=False,
|
|
org_id="",
|
|
created_by="",
|
|
origin="mcp",
|
|
mcp_server="srv",
|
|
readonly=True,
|
|
)
|
|
tpl = db.get_prompt_template("t1")
|
|
assert tpl is not None
|
|
assert tpl["readonly"] is True
|
|
# Simulate API guard check
|
|
assert tpl.get("readonly") is True
|
|
|
|
def test_readonly_guard_delete(self, db) -> None:
|
|
"""Readonly templates are flagged for API-level rejection."""
|
|
db.create_prompt_template(
|
|
"t1",
|
|
"mcp__srv__prompt",
|
|
"mcp",
|
|
"content",
|
|
variables="[]",
|
|
is_default=False,
|
|
org_id="",
|
|
created_by="",
|
|
origin="mcp",
|
|
mcp_server="srv",
|
|
readonly=True,
|
|
)
|
|
existing = db.get_prompt_template("t1")
|
|
assert existing is not None
|
|
assert existing.get("readonly") is True
|