diff --git a/src/config/extraNavItems.test.ts b/src/config/extraNavItems.test.ts
new file mode 100644
index 000000000..353032d83
--- /dev/null
+++ b/src/config/extraNavItems.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { getExtraNavItems } from "./extraNavItems";
+
+describe("extraNavItems", () => {
+ afterEach(() => {
+ delete window.__TANGLE_EXTRA_NAV_ITEMS__;
+ });
+
+ it("returns nothing when the host page injects no items", () => {
+ expect(getExtraNavItems(["admin"])).toEqual([]);
+ });
+
+ it("returns items that require no permission", () => {
+ window.__TANGLE_EXTRA_NAV_ITEMS__ = [
+ { label: "Runbook", href: "https://example.com/runbook", external: true },
+ ];
+
+ expect(getExtraNavItems([])).toEqual([
+ { label: "Runbook", href: "https://example.com/runbook", external: true },
+ ]);
+ });
+
+ it("hides items whose required permission the user lacks", () => {
+ window.__TANGLE_EXTRA_NAV_ITEMS__ = [
+ {
+ label: "Admin",
+ href: "/admin/",
+ icon: "ShieldCheck",
+ requiresPermission: "admin",
+ },
+ ];
+
+ expect(getExtraNavItems(["read", "write"])).toEqual([]);
+ expect(getExtraNavItems(["read", "write", "admin"])).toEqual([
+ {
+ label: "Admin",
+ href: "/admin/",
+ icon: "ShieldCheck",
+ requiresPermission: "admin",
+ },
+ ]);
+ });
+
+ it("drops items without a usable label or href", () => {
+ window.__TANGLE_EXTRA_NAV_ITEMS__ = [
+ { label: " ", href: "/admin/" },
+ { label: "No href" } as never,
+ "not an object" as never,
+ ];
+
+ expect(getExtraNavItems([])).toEqual([]);
+ });
+
+ it("rejects hrefs that are neither same-origin paths nor http(s) URLs", () => {
+ window.__TANGLE_EXTRA_NAV_ITEMS__ = [
+ { label: "Script", href: "javascript:alert(1)" },
+ { label: "Data", href: "data:text/html," },
+ { label: "Relative", href: "admin/" },
+ ];
+
+ expect(getExtraNavItems([])).toEqual([]);
+ });
+
+ it("rejects leading-slash hrefs that resolve off-origin", () => {
+ window.__TANGLE_EXTRA_NAV_ITEMS__ = [
+ { label: "Protocol relative", href: "//evil.com" },
+ { label: "Backslash authority", href: "/\\evil.com" },
+ ];
+
+ expect(getExtraNavItems([])).toEqual([]);
+ });
+
+ it("keeps the path, query and hash of a same-origin href", () => {
+ window.__TANGLE_EXTRA_NAV_ITEMS__ = [
+ { label: "Admin", href: "/admin/?tab=users#top" },
+ ];
+
+ expect(getExtraNavItems([])).toEqual([
+ { label: "Admin", href: "/admin/?tab=users#top" },
+ ]);
+ });
+
+ it("keeps an item but drops an icon name the icon set does not have", () => {
+ window.__TANGLE_EXTRA_NAV_ITEMS__ = [
+ { label: "Admin", href: "/admin/", icon: "NotARealIcon" as never },
+ ];
+
+ expect(getExtraNavItems([])).toEqual([{ label: "Admin", href: "/admin/" }]);
+ });
+});
diff --git a/src/config/extraNavItems.ts b/src/config/extraNavItems.ts
new file mode 100644
index 000000000..17b35e411
--- /dev/null
+++ b/src/config/extraNavItems.ts
@@ -0,0 +1,92 @@
+import { icons } from "lucide-react";
+
+import type { IconName } from "@/components/ui/icon";
+import { isRecord } from "@/utils/typeGuards";
+
+export interface ExtraNavItem {
+ label: string;
+ href: string;
+ icon?: IconName;
+ requiresPermission?: string;
+ external?: boolean;
+}
+
+declare global {
+ interface Window {
+ __TANGLE_EXTRA_NAV_ITEMS__?: ExtraNavItem[];
+ }
+}
+
+function readIconName(value: unknown): IconName | undefined {
+ if (typeof value !== "string" || !Object.hasOwn(icons, value)) {
+ return undefined;
+ }
+ return value as IconName;
+}
+
+/**
+ * Only same-origin paths and http(s) URLs are accepted, so a host page that
+ * assembles this config from somewhere less trusted than its own markup cannot
+ * turn a nav item into a `javascript:` sink.
+ *
+ * A leading slash is not enough to call a path same-origin: `//evil.com` and its
+ * backslash variants resolve off-origin, so the path branch resolves against the
+ * current origin and keeps only what still belongs to it.
+ */
+function readHref(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+
+ const href = value.trim();
+ if (href.startsWith("/")) {
+ try {
+ const url = new URL(href, window.location.origin);
+ return url.origin === window.location.origin
+ ? url.pathname + url.search + url.hash
+ : null;
+ } catch {
+ return null;
+ }
+ }
+
+ return /^https?:\/\//i.test(href) ? href : null;
+}
+
+function readTrimmedString(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ return value.trim() || undefined;
+}
+
+function readNavItem(value: unknown): ExtraNavItem | null {
+ if (!isRecord(value)) return null;
+
+ const href = readHref(value.href);
+ const label = readTrimmedString(value.label);
+ if (!href || !label) return null;
+
+ const icon = readIconName(value.icon);
+ const requiresPermission = readTrimmedString(value.requiresPermission);
+
+ return {
+ label,
+ href,
+ ...(icon ? { icon } : {}),
+ ...(requiresPermission ? { requiresPermission } : {}),
+ ...(value.external === true ? { external: true } : {}),
+ };
+}
+
+export function getExtraNavItems(permissions: string[]): ExtraNavItem[] {
+ if (typeof window === "undefined") return [];
+
+ const injected = window.__TANGLE_EXTRA_NAV_ITEMS__;
+ if (!Array.isArray(injected)) return [];
+
+ return injected
+ .map(readNavItem)
+ .filter((item) => item !== null)
+ .filter(
+ (item) =>
+ !item.requiresPermission ||
+ permissions.includes(item.requiresPermission),
+ );
+}
diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx
index 208db0124..bc5557818 100644
--- a/src/routes/Dashboard/DashboardLayout.tsx
+++ b/src/routes/Dashboard/DashboardLayout.tsx
@@ -11,6 +11,7 @@ import { Text } from "@/components/ui/typography";
import { cn } from "@/lib/utils";
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
import { APP_ROUTES } from "@/routes/appRoutes";
+import { ExtraNavItems } from "@/routes/Dashboard/ExtraNavItems";
import {
ABOUT_URL,
DOCUMENTATION_URL,
@@ -154,6 +155,7 @@ export function DashboardLayout() {
)}
+