diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ef45bc9a..dfa69288 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,9 @@ on:
pull_request:
branches: [main, "stable/*"]
+permissions:
+ contents: read
+
jobs:
lint:
runs-on: ubuntu-latest
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index c2cf2412..3c381c08 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -19,7 +19,9 @@ env:
jobs:
docker:
- if: github.event.workflow_run.conclusion == 'success'
+ if: >-
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
diff --git a/turnstone/console/server.py b/turnstone/console/server.py
index f0e9786b..37b32896 100644
--- a/turnstone/console/server.py
+++ b/turnstone/console/server.py
@@ -5485,8 +5485,14 @@ async def admin_detect_model(request: Request) -> JSONResponse:
base_url = row.get("base_url", "")
# For commercial endpoints an api_key is required
+ _normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else ""
+ _hostname = (urllib.parse.urlparse(_normalized).hostname or "") if _normalized else ""
if not api_key and (
- not base_url or "api.openai.com" in base_url or "api.anthropic.com" in base_url
+ not base_url
+ or _hostname == "api.openai.com"
+ or _hostname.endswith(".openai.com")
+ or _hostname == "api.anthropic.com"
+ or _hostname.endswith(".anthropic.com")
):
return JSONResponse({"error": "api_key is required"}, status_code=400)
diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js
index 9ed63c9c..76758485 100644
--- a/turnstone/console/static/app.js
+++ b/turnstone/console/static/app.js
@@ -720,7 +720,9 @@ function buildNodeRow(node) {
function toggleGroup(prefix) {
expandedGroups[prefix] = !expandedGroups[prefix];
var body = document.querySelector(
- '.node-group-body[data-prefix="' + prefix.replace(/"/g, '\\"') + '"]',
+ '.node-group-body[data-prefix="' +
+ prefix.replace(/\\/g, "\\\\").replace(/"/g, '\\"') +
+ '"]',
);
if (!body) return;
var isExpanded = expandedGroups[prefix];
diff --git a/turnstone/core/model_registry.py b/turnstone/core/model_registry.py
index a113eac3..95a24dd3 100644
--- a/turnstone/core/model_registry.py
+++ b/turnstone/core/model_registry.py
@@ -546,7 +546,11 @@ def _detect_openai_compat(
result["context_window"] = known["context_window"]
# Server type heuristics
- if base_url and "api.openai.com" in base_url:
+ from urllib.parse import urlparse
+
+ _normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else ""
+ _hostname = urlparse(_normalized).hostname or "" if _normalized else ""
+ if base_url and (_hostname == "api.openai.com" or _hostname.endswith(".openai.com")):
result["server_type"] = "openai"
elif meta is not None and "n_ctx_train" in meta:
result["server_type"] = "llama.cpp"
diff --git a/turnstone/eval.py b/turnstone/eval.py
index fc9bad60..40f6399b 100644
--- a/turnstone/eval.py
+++ b/turnstone/eval.py
@@ -45,7 +45,11 @@ _MCP_ONLY_TOOLS = frozenset({"read_resource", "use_prompt"})
def _detect_provider(base_url: str) -> str:
"""Infer provider name from a base URL."""
- if "anthropic.com" in base_url:
+ from urllib.parse import urlparse
+
+ normalized = base_url if "://" in base_url else f"https://{base_url}"
+ hostname = urlparse(normalized).hostname or ""
+ if hostname == "anthropic.com" or hostname.endswith(".anthropic.com"):
return "anthropic"
return "openai"
diff --git a/turnstone/ui/static/renderer.js b/turnstone/ui/static/renderer.js
index 8ed07e0e..76de8920 100644
--- a/turnstone/ui/static/renderer.js
+++ b/turnstone/ui/static/renderer.js
@@ -26,6 +26,7 @@ function inlineMarkdown(text) {
text = text.replace(/~~(.+?)~~/g, "$1");
// Images (must come before links — render as click-to-load placeholder)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (m, alt, url) {
+ if (!/^\s*(https?:\/\/|data:image\/)/i.test(url)) return m;
var safeAlt = alt || "Image";
var domain = "";
try {
@@ -53,9 +54,9 @@ function inlineMarkdown(text) {
""
);
});
- // Links (block javascript: scheme)
+ // Links (allow http, https, and same-origin relative URLs only)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, url) {
- if (/^\s*javascript:/i.test(url)) return m;
+ if (!/^\s*(https?:\/\/|\/(?!\/))/i.test(url)) return m;
return (
'