From 141c1706b6addc3b83475705f550eef2200b10f7 Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Mon, 17 Aug 2026 00:22:51 +0200 Subject: [PATCH 1/6] feat(guardrails): project-level overrides Guardrails could only be configured for a whole organization. Projects now get the same settings page, defaulting to the organization config and overridable per project so a single app can run stricter or looser rules. - guardrail_config/guardrail_rule gain a nullable project_id; a project row with inherit_organization=false replaces the org config and its rules - project-scoped API routes mirror the org ones, restricted to org owners and admins, with rules addressable only within their own scope - both pages state which scope wins: the project page shows the inherited org settings read-only, the org page lists projects that override it Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/routes/admin-org-details.ts | 1 + .../api/src/routes/guardrails-project.spec.ts | 258 + apps/api/src/routes/guardrails.ts | 733 +- apps/docs/content/features/guardrails.mdx | 8 + apps/docs/content/learn/guardrails.mdx | 6 + apps/gateway/src/chat/chat.ts | 1 + .../[projectId]/settings/guardrails/page.tsx | 24 + .../org/guardrails/contact-sales-card.tsx | 79 - .../org/guardrails/guardrails-client.tsx | 790 - .../dashboard/[orgId]/org/guardrails/page.tsx | 25 +- .../dashboard/dashboard-sidebar.tsx | 14 + .../guardrails/contact-sales-card.tsx | 69 + .../guardrails/guardrails-settings.tsx | 823 + apps/ui/src/hooks/useGuardrails.ts | 259 + ee/guardrails/src/engine.ts | 147 +- ee/guardrails/src/index.ts | 2 + ee/guardrails/src/scope.spec.ts | 179 + ee/guardrails/src/types.ts | 8 + .../db/migrations/1786917280_kind_zarek.sql | 9 + .../migrations/meta/1786917280_snapshot.json | 32217 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/relations.ts | 19 + packages/db/src/schema.ts | 22 +- packages/db/src/seed.ts | 45 + 24 files changed, 34727 insertions(+), 1018 deletions(-) create mode 100644 apps/api/src/routes/guardrails-project.spec.ts create mode 100644 apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/guardrails/page.tsx delete mode 100644 apps/ui/src/app/dashboard/[orgId]/org/guardrails/contact-sales-card.tsx delete mode 100644 apps/ui/src/app/dashboard/[orgId]/org/guardrails/guardrails-client.tsx create mode 100644 apps/ui/src/components/guardrails/contact-sales-card.tsx create mode 100644 apps/ui/src/components/guardrails/guardrails-settings.tsx create mode 100644 apps/ui/src/hooks/useGuardrails.ts create mode 100644 ee/guardrails/src/scope.spec.ts create mode 100644 packages/db/migrations/1786917280_kind_zarek.sql create mode 100644 packages/db/migrations/meta/1786917280_snapshot.json diff --git a/apps/api/src/routes/admin-org-details.ts b/apps/api/src/routes/admin-org-details.ts index c8a397ea53..694a57eebf 100644 --- a/apps/api/src/routes/admin-org-details.ts +++ b/apps/api/src/routes/admin-org-details.ts @@ -467,6 +467,7 @@ adminOrgDetails.openapi(getOrganizationGuardrails, async (c) => { db.query.guardrailConfig.findFirst({ where: { organizationId: { eq: orgId }, + projectId: { isNull: true }, }, }), db.query.guardrailRule.findMany({ diff --git a/apps/api/src/routes/guardrails-project.spec.ts b/apps/api/src/routes/guardrails-project.spec.ts new file mode 100644 index 0000000000..d875b02746 --- /dev/null +++ b/apps/api/src/routes/guardrails-project.spec.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { app } from "@/index.js"; +import { createTestUser, deleteAll } from "@/testing.js"; + +import { db, eq, tables } from "@llmgateway/db"; + +describe("project guardrails API", () => { + let token: string; + + afterEach(async () => { + await deleteAll(); + }); + + beforeEach(async () => { + token = await createTestUser(); + + await db.insert(tables.organization).values({ + id: "test-org-id", + name: "Test Organization", + billingEmail: "test@example.com", + plan: "enterprise", + }); + + await db.insert(tables.userOrganization).values({ + id: "test-user-org-id", + userId: "test-user-id", + organizationId: "test-org-id", + }); + + await db.insert(tables.project).values([ + { + id: "test-project-id", + name: "Test Project", + organizationId: "test-org-id", + }, + { + id: "other-project-id", + name: "Other Project", + organizationId: "test-org-id", + }, + ]); + }); + + function authed(path: string, init: RequestInit = {}) { + return app.request(path, { + ...init, + headers: { + "Content-Type": "application/json", + Cookie: token, + }, + }); + } + + test("requires authentication", async () => { + const res = await app.request( + "/guardrails/projects/test-project-id/config", + ); + expect(res.status).toBe(401); + }); + + test("developers cannot manage project guardrails", async () => { + await db + .update(tables.userOrganization) + .set({ role: "developer" }) + .where(eq(tables.userOrganization.id, "test-user-org-id")); + + const res = await authed("/guardrails/projects/test-project-id/config"); + expect(res.status).toBe(403); + }); + + test("non-enterprise organizations are rejected", async () => { + await db + .update(tables.organization) + .set({ plan: "pro" }) + .where(eq(tables.organization.id, "test-org-id")); + + const res = await authed("/guardrails/projects/test-project-id/config"); + expect(res.status).toBe(403); + }); + + test("rejects a project outside the user's organizations", async () => { + await db.insert(tables.organization).values({ + id: "foreign-org-id", + name: "Foreign Organization", + billingEmail: "foreign@example.com", + plan: "enterprise", + }); + await db.insert(tables.project).values({ + id: "foreign-project-id", + name: "Foreign Project", + organizationId: "foreign-org-id", + }); + + const res = await authed("/guardrails/projects/foreign-project-id/config"); + expect(res.status).toBe(403); + }); + + test("project config starts empty and defaults to inheriting", async () => { + const res = await authed("/guardrails/projects/test-project-id/config"); + expect(res.status).toBe(200); + expect(await res.json()).toBeNull(); + + const saved = await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ enabled: true }), + }); + expect(saved.status).toBe(200); + + const body = await saved.json(); + expect(body.projectId).toBe("test-project-id"); + expect(body.organizationId).toBe("test-org-id"); + expect(body.inheritOrganization).toBe(true); + }); + + test("organization and project configs are stored separately", async () => { + await authed("/guardrails/config/test-org-id", { + method: "PUT", + body: JSON.stringify({ enabled: true, maxFileSizeMb: 25 }), + }); + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ + inheritOrganization: false, + enabled: true, + maxFileSizeMb: 5, + }), + }); + + const orgConfig = await ( + await authed("/guardrails/config/test-org-id") + ).json(); + expect(orgConfig.projectId).toBeNull(); + expect(orgConfig.maxFileSizeMb).toBe(25); + + const projectConfig = await ( + await authed("/guardrails/projects/test-project-id/config") + ).json(); + expect(projectConfig.maxFileSizeMb).toBe(5); + expect(projectConfig.inheritOrganization).toBe(false); + }); + + test("rules are scoped to the level they were created on", async () => { + const orgRule = await ( + await authed("/guardrails/rules/test-org-id", { + method: "POST", + body: JSON.stringify({ + name: "Org rule", + type: "blocked_terms", + config: { + type: "blocked_terms", + terms: ["org"], + matchType: "contains", + caseSensitive: false, + }, + }), + }) + ).json(); + expect(orgRule.projectId).toBeNull(); + + const projectRule = await ( + await authed("/guardrails/projects/test-project-id/rules", { + method: "POST", + body: JSON.stringify({ + name: "Project rule", + type: "blocked_terms", + config: { + type: "blocked_terms", + terms: ["project"], + matchType: "contains", + caseSensitive: false, + }, + }), + }) + ).json(); + expect(projectRule.projectId).toBe("test-project-id"); + + const orgRules = await ( + await authed("/guardrails/rules/test-org-id") + ).json(); + expect(orgRules.rules.map((r: { name: string }) => r.name)).toEqual([ + "Org rule", + ]); + + const projectRules = await ( + await authed("/guardrails/projects/test-project-id/rules") + ).json(); + expect(projectRules.rules.map((r: { name: string }) => r.name)).toEqual([ + "Project rule", + ]); + + // A project route must not reach an organization rule, nor another + // project's rule. + const crossScope = await authed( + `/guardrails/projects/test-project-id/rules/${orgRule.id}`, + { method: "DELETE" }, + ); + expect(crossScope.status).toBe(404); + + const otherProject = await authed( + `/guardrails/projects/other-project-id/rules/${projectRule.id}`, + { method: "PATCH", body: JSON.stringify({ enabled: false }) }, + ); + expect(otherProject.status).toBe(404); + + const orgRouteOnProjectRule = await authed( + `/guardrails/rules/test-org-id/${projectRule.id}`, + { method: "DELETE" }, + ); + expect(orgRouteOnProjectRule.status).toBe(404); + }); + + test("project overrides are listed for the organization", async () => { + const empty = await ( + await authed("/guardrails/config/test-org-id/project-overrides") + ).json(); + expect(empty.projects).toEqual([]); + + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ inheritOrganization: true, enabled: true }), + }); + + const stillInheriting = await ( + await authed("/guardrails/config/test-org-id/project-overrides") + ).json(); + expect(stillInheriting.projects).toEqual([]); + + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ inheritOrganization: false }), + }); + + const overrides = await ( + await authed("/guardrails/config/test-org-id/project-overrides") + ).json(); + expect(overrides.projects).toEqual([ + { id: "test-project-id", name: "Test Project", enabled: true }, + ]); + }); + + test("resetting a project config keeps it project-scoped", async () => { + await authed("/guardrails/projects/test-project-id/config", { + method: "PUT", + body: JSON.stringify({ inheritOrganization: false, maxFileSizeMb: 99 }), + }); + + const reset = await ( + await authed("/guardrails/projects/test-project-id/config/reset", { + method: "POST", + }) + ).json(); + + expect(reset.projectId).toBe("test-project-id"); + expect(reset.inheritOrganization).toBe(true); + expect(reset.maxFileSizeMb).toBe(10); + }); +}); diff --git a/apps/api/src/routes/guardrails.ts b/apps/api/src/routes/guardrails.ts index f55a75b7d2..63c10d6a7b 100644 --- a/apps/api/src/routes/guardrails.ts +++ b/apps/api/src/routes/guardrails.ts @@ -27,6 +27,15 @@ import type { export const guardrails = new OpenAPIHono(); +/** + * The scope a guardrail request operates on: the organization-level config + * (`projectId: null`) or a single project's override. + */ +interface GuardrailScopeContext { + organizationId: string; + projectId: string | null; +} + // Helper to check enterprise access async function checkEnterpriseAccess( userId: string, @@ -65,6 +74,60 @@ async function checkEnterpriseAccess( return { userOrg }; } +async function requireOrgScope( + userId: string, + organizationId: string, +): Promise { + await checkEnterpriseAccess(userId, organizationId); + return { organizationId, projectId: null }; +} + +/** + * Project guardrails are administered by the project's owners and admins. + * Roles are organization-level, so this reuses the organization check against + * the project's owning organization — project-scoped "developer" members are + * excluded there. + */ +async function requireProjectScope( + userId: string, + projectId: string, +): Promise { + const project = await db.query.project.findFirst({ + where: { id: { eq: projectId } }, + }); + + if (!project || project.status === "deleted") { + throw new HTTPException(404, { message: "Project not found" }); + } + + await checkEnterpriseAccess(userId, project.organizationId); + + return { organizationId: project.organizationId, projectId }; +} + +function findScopedConfig(scope: GuardrailScopeContext) { + return db.query.guardrailConfig.findFirst({ + where: scope.projectId + ? { projectId: { eq: scope.projectId } } + : { + organizationId: { eq: scope.organizationId }, + projectId: { isNull: true }, + }, + }); +} + +function findScopedRules(scope: GuardrailScopeContext) { + return db.query.guardrailRule.findMany({ + where: scope.projectId + ? { projectId: { eq: scope.projectId } } + : { + organizationId: { eq: scope.organizationId }, + projectId: { isNull: true }, + }, + orderBy: { priority: "desc" }, + }); +} + // Schemas const systemRuleConfigSchema = z.object({ enabled: z.boolean(), @@ -83,6 +146,8 @@ const systemRulesConfigSchema = z.object({ const guardrailConfigSchema = z.object({ id: z.string(), organizationId: z.string(), + projectId: z.string().nullable(), + inheritOrganization: z.boolean(), enabled: z.boolean(), systemRules: systemRulesConfigSchema.nullable(), maxFileSizeMb: z.number(), @@ -119,6 +184,7 @@ const customRuleConfigSchema = z.union([ const guardrailRuleSchema = z.object({ id: z.string(), organizationId: z.string(), + projectId: z.string().nullable(), name: z.string(), type: z.enum(customRuleTypes), config: customRuleConfigSchema, @@ -173,70 +239,70 @@ guardrails.openapi(getConfig, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); - - const config = await db.query.guardrailConfig.findFirst({ - where: { organizationId: { eq: organizationId } }, - }); + const scope = await requireOrgScope(user.id, organizationId); - return c.json(config ?? null); + return c.json((await findScopedConfig(scope)) ?? null); }); -// PUT /guardrails/config/:organizationId - Update or create config -const updateConfig = createRoute({ - method: "put", - path: "/config/{organizationId}", +// GET /guardrails/projects/:projectId/config - Get project config +const getProjectConfig = createRoute({ + method: "get", + path: "/projects/{projectId}/config", request: { params: z.object({ - organizationId: z.string(), + projectId: z.string(), }), - body: { - content: { - "application/json": { - schema: z.object({ - enabled: z.boolean().optional(), - systemRules: systemRulesConfigSchema.optional(), - maxFileSizeMb: z.number().optional(), - allowedFileTypes: z.array(z.string()).optional(), - piiAction: z.enum(["block", "redact", "warn", "allow"]).optional(), - }), - }, - }, - }, }, responses: { 200: { content: { "application/json": { - schema: guardrailConfigSchema, + schema: guardrailConfigSchema.nullable(), }, }, - description: "Updated guardrail configuration", + description: "Project guardrail configuration", }, }, }); -guardrails.openapi(updateConfig, async (c) => { +guardrails.openapi(getProjectConfig, async (c) => { const user = c.get("user"); if (!user) { throw new HTTPException(401, { message: "Unauthorized" }); } - const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); - const body = c.req.valid("json"); + return c.json((await findScopedConfig(scope)) ?? null); +}); - // Check if config exists - const existing = await db.query.guardrailConfig.findFirst({ - where: { organizationId: { eq: organizationId } }, - }); +const updateConfigBodySchema = z.object({ + enabled: z.boolean().optional(), + systemRules: systemRulesConfigSchema.optional(), + maxFileSizeMb: z.number().optional(), + allowedFileTypes: z.array(z.string()).optional(), + piiAction: z.enum(["block", "redact", "warn", "allow"]).optional(), +}); + +// `inheritOrganization` only exists on project rows: while true the project +// falls back to the organization config, which is the default for new projects. +const updateProjectConfigBodySchema = updateConfigBodySchema.extend({ + inheritOrganization: z.boolean().optional(), +}); + +async function upsertScopedConfig( + scope: GuardrailScopeContext, + body: z.infer, +) { + const existing = await findScopedConfig(scope); if (existing) { - // Update existing config const [updated] = await db .update(tables.guardrailConfig) .set({ + inheritOrganization: + body.inheritOrganization ?? existing.inheritOrganization, enabled: body.enabled ?? existing.enabled, systemRules: (body.systemRules as SystemRulesConfig) ?? existing.systemRules, @@ -246,14 +312,15 @@ guardrails.openapi(updateConfig, async (c) => { }) .where(eq(tables.guardrailConfig.id, existing.id)) .returning(); - return c.json(updated); + return updated; } - // Create new config const [created] = await db .insert(tables.guardrailConfig) .values({ - organizationId, + organizationId: scope.organizationId, + projectId: scope.projectId, + inheritOrganization: body.inheritOrganization ?? true, enabled: body.enabled ?? true, systemRules: (body.systemRules as SystemRulesConfig) ?? defaultSystemRulesConfig, @@ -263,7 +330,171 @@ guardrails.openapi(updateConfig, async (c) => { }) .returning(); - return c.json(created); + return created; +} + +async function resetScopedConfig(scope: GuardrailScopeContext) { + const existing = await findScopedConfig(scope); + if (existing) { + await db + .delete(tables.guardrailConfig) + .where(eq(tables.guardrailConfig.id, existing.id)); + } + + const [created] = await db + .insert(tables.guardrailConfig) + .values({ + organizationId: scope.organizationId, + projectId: scope.projectId, + inheritOrganization: true, + enabled: true, + systemRules: defaultSystemRulesConfig, + maxFileSizeMb: 10, + allowedFileTypes: defaultAllowedFileTypes, + piiAction: "redact", + }) + .returning(); + + return created; +} + +// PUT /guardrails/config/:organizationId - Update or create config +const updateConfig = createRoute({ + method: "put", + path: "/config/{organizationId}", + request: { + params: z.object({ + organizationId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateConfigBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailConfigSchema, + }, + }, + description: "Updated guardrail configuration", + }, + }, +}); + +guardrails.openapi(updateConfig, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId } = c.req.param(); + const scope = await requireOrgScope(user.id, organizationId); + + return c.json(await upsertScopedConfig(scope, c.req.valid("json"))); +}); + +// GET /guardrails/config/:organizationId/project-overrides - Projects that +// have opted out of the organization config +const listProjectOverrides = createRoute({ + method: "get", + path: "/config/{organizationId}/project-overrides", + request: { + params: z.object({ + organizationId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + projects: z.array( + z.object({ + id: z.string(), + name: z.string(), + enabled: z.boolean(), + }), + ), + }), + }, + }, + description: "Projects overriding the organization guardrails", + }, + }, +}); + +guardrails.openapi(listProjectOverrides, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId } = c.req.param(); + await requireOrgScope(user.id, organizationId); + + const configs = await db.query.guardrailConfig.findMany({ + where: { + organizationId: { eq: organizationId }, + projectId: { isNotNull: true }, + inheritOrganization: { eq: false }, + }, + with: { project: true }, + }); + + return c.json({ + projects: configs + .filter((config) => config.project?.status !== "deleted") + .map((config) => ({ + id: config.projectId!, + name: config.project?.name ?? "Unknown project", + enabled: config.enabled, + })), + }); +}); + +// PUT /guardrails/projects/:projectId/config - Update or create project config +const updateProjectConfig = createRoute({ + method: "put", + path: "/projects/{projectId}/config", + request: { + params: z.object({ + projectId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateProjectConfigBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailConfigSchema, + }, + }, + description: "Updated project guardrail configuration", + }, + }, +}); + +guardrails.openapi(updateProjectConfig, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json(await upsertScopedConfig(scope, c.req.valid("json"))); }); // POST /guardrails/config/:organizationId/reset - Reset to defaults @@ -294,26 +525,42 @@ guardrails.openapi(resetConfig, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); - // Delete existing and create new with defaults - await db - .delete(tables.guardrailConfig) - .where(eq(tables.guardrailConfig.organizationId, organizationId)); + return c.json(await resetScopedConfig(scope)); +}); - const [created] = await db - .insert(tables.guardrailConfig) - .values({ - organizationId, - enabled: true, - systemRules: defaultSystemRulesConfig, - maxFileSizeMb: 10, - allowedFileTypes: defaultAllowedFileTypes, - piiAction: "redact", - }) - .returning(); +// POST /guardrails/projects/:projectId/config/reset - Reset project to defaults +const resetProjectConfig = createRoute({ + method: "post", + path: "/projects/{projectId}/config/reset", + request: { + params: z.object({ + projectId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailConfigSchema, + }, + }, + description: "Reset project guardrail configuration", + }, + }, +}); - return c.json(created); +guardrails.openapi(resetProjectConfig, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json(await resetScopedConfig(scope)); }); // GET /guardrails/rules/:organizationId - List custom rules @@ -346,16 +593,76 @@ guardrails.openapi(listRules, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); - const rules = await db.query.guardrailRule.findMany({ - where: { organizationId: { eq: organizationId } }, - orderBy: { priority: "desc" }, - }); + return c.json({ rules: await findScopedRules(scope) }); +}); - return c.json({ rules }); +// GET /guardrails/projects/:projectId/rules - List project custom rules +const listProjectRules = createRoute({ + method: "get", + path: "/projects/{projectId}/rules", + request: { + params: z.object({ + projectId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + rules: z.array(guardrailRuleSchema), + }), + }, + }, + description: "List of project custom guardrail rules", + }, + }, +}); + +guardrails.openapi(listProjectRules, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json({ rules: await findScopedRules(scope) }); }); +const createRuleBodySchema = z.object({ + name: z.string(), + type: z.enum(customRuleTypes), + config: customRuleConfigSchema, + priority: z.number().optional(), + enabled: z.boolean().optional(), + action: z.enum(["block", "redact", "warn", "allow"]).optional(), +}); + +async function createScopedRule( + scope: GuardrailScopeContext, + body: z.infer, +) { + const [created] = await db + .insert(tables.guardrailRule) + .values({ + organizationId: scope.organizationId, + projectId: scope.projectId, + name: body.name, + type: body.type, + config: body.config as CustomRuleConfig, + priority: body.priority ?? 100, + enabled: body.enabled ?? true, + action: (body.action as GuardrailAction) ?? "block", + }) + .returning(); + + return created; +} + // POST /guardrails/rules/:organizationId - Create custom rule const createRule = createRoute({ method: "post", @@ -367,14 +674,7 @@ const createRule = createRoute({ body: { content: { "application/json": { - schema: z.object({ - name: z.string(), - type: z.enum(customRuleTypes), - config: customRuleConfigSchema, - priority: z.number().optional(), - enabled: z.boolean().optional(), - action: z.enum(["block", "redact", "warn", "allow"]).optional(), - }), + schema: createRuleBodySchema, }, }, }, @@ -398,45 +698,23 @@ guardrails.openapi(createRule, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); - const body = c.req.valid("json"); - - const [created] = await db - .insert(tables.guardrailRule) - .values({ - organizationId, - name: body.name, - type: body.type, - config: body.config as CustomRuleConfig, - priority: body.priority ?? 100, - enabled: body.enabled ?? true, - action: (body.action as GuardrailAction) ?? "block", - }) - .returning(); - - return c.json(created); + return c.json(await createScopedRule(scope, c.req.valid("json"))); }); -// PATCH /guardrails/rules/:organizationId/:ruleId - Update custom rule -const updateRule = createRoute({ - method: "patch", - path: "/rules/{organizationId}/{ruleId}", +// POST /guardrails/projects/:projectId/rules - Create project custom rule +const createProjectRule = createRoute({ + method: "post", + path: "/projects/{projectId}/rules", request: { params: z.object({ - organizationId: z.string(), - ruleId: z.string(), + projectId: z.string(), }), body: { content: { "application/json": { - schema: z.object({ - name: z.string().optional(), - config: customRuleConfigSchema.optional(), - priority: z.number().optional(), - enabled: z.boolean().optional(), - action: z.enum(["block", "redact", "warn", "allow"]).optional(), - }), + schema: createRuleBodySchema, }, }, }, @@ -448,33 +726,60 @@ const updateRule = createRoute({ schema: guardrailRuleSchema, }, }, - description: "Updated custom rule", + description: "Created project custom rule", }, }, }); -guardrails.openapi(updateRule, async (c) => { +guardrails.openapi(createProjectRule, async (c) => { const user = c.get("user"); if (!user) { throw new HTTPException(401, { message: "Unauthorized" }); } - const { organizationId, ruleId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); - const body = c.req.valid("json"); + return c.json(await createScopedRule(scope, c.req.valid("json"))); +}); + +const updateRuleBodySchema = z.object({ + name: z.string().optional(), + config: customRuleConfigSchema.optional(), + priority: z.number().optional(), + enabled: z.boolean().optional(), + action: z.enum(["block", "redact", "warn", "allow"]).optional(), +}); +/** + * Look up a rule within its scope so an organization rule can never be edited + * or deleted through a project route, and vice versa. + */ +async function findScopedRule(scope: GuardrailScopeContext, ruleId: string) { const existing = await db.query.guardrailRule.findFirst({ - where: { - id: { eq: ruleId }, - organizationId: { eq: organizationId }, - }, + where: scope.projectId + ? { id: { eq: ruleId }, projectId: { eq: scope.projectId } } + : { + id: { eq: ruleId }, + organizationId: { eq: scope.organizationId }, + projectId: { isNull: true }, + }, }); if (!existing) { throw new HTTPException(404, { message: "Rule not found" }); } + return existing; +} + +async function updateScopedRule( + scope: GuardrailScopeContext, + ruleId: string, + body: z.infer, +) { + const existing = await findScopedRule(scope, ruleId); + const [updated] = await db .update(tables.guardrailRule) .set({ @@ -484,10 +789,100 @@ guardrails.openapi(updateRule, async (c) => { enabled: body.enabled ?? existing.enabled, action: (body.action as GuardrailAction) ?? existing.action, }) - .where(eq(tables.guardrailRule.id, ruleId)) + .where(eq(tables.guardrailRule.id, existing.id)) .returning(); - return c.json(updated); + return updated; +} + +async function deleteScopedRule(scope: GuardrailScopeContext, ruleId: string) { + const existing = await findScopedRule(scope, ruleId); + + await db + .delete(tables.guardrailRule) + .where(eq(tables.guardrailRule.id, existing.id)); +} + +// PATCH /guardrails/rules/:organizationId/:ruleId - Update custom rule +const updateRule = createRoute({ + method: "patch", + path: "/rules/{organizationId}/{ruleId}", + request: { + params: z.object({ + organizationId: z.string(), + ruleId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateRuleBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailRuleSchema, + }, + }, + description: "Updated custom rule", + }, + }, +}); + +guardrails.openapi(updateRule, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { organizationId, ruleId } = c.req.param(); + const scope = await requireOrgScope(user.id, organizationId); + + return c.json(await updateScopedRule(scope, ruleId, c.req.valid("json"))); +}); + +// PATCH /guardrails/projects/:projectId/rules/:ruleId - Update project rule +const updateProjectRule = createRoute({ + method: "patch", + path: "/projects/{projectId}/rules/{ruleId}", + request: { + params: z.object({ + projectId: z.string(), + ruleId: z.string(), + }), + body: { + content: { + "application/json": { + schema: updateRuleBodySchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: guardrailRuleSchema, + }, + }, + description: "Updated project custom rule", + }, + }, +}); + +guardrails.openapi(updateProjectRule, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId, ruleId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + return c.json(await updateScopedRule(scope, ruleId, c.req.valid("json"))); }); // DELETE /guardrails/rules/:organizationId/:ruleId - Delete custom rule @@ -519,16 +914,43 @@ guardrails.openapi(deleteRule, async (c) => { } const { organizationId, ruleId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + const scope = await requireOrgScope(user.id, organizationId); + await deleteScopedRule(scope, ruleId); - await db - .delete(tables.guardrailRule) - .where( - and( - eq(tables.guardrailRule.id, ruleId), - eq(tables.guardrailRule.organizationId, organizationId), - ), - ); + return c.json({ success: true }); +}); + +// DELETE /guardrails/projects/:projectId/rules/:ruleId - Delete project rule +const deleteProjectRule = createRoute({ + method: "delete", + path: "/projects/{projectId}/rules/{ruleId}", + request: { + params: z.object({ + projectId: z.string(), + ruleId: z.string(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + description: "Project rule deleted", + }, + }, +}); + +guardrails.openapi(deleteProjectRule, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId, ruleId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + await deleteScopedRule(scope, ruleId); return c.json({ success: true }); }); @@ -816,7 +1238,7 @@ guardrails.openapi(testContent, async (c) => { } const { organizationId } = c.req.param(); - await checkEnterpriseAccess(user.id, organizationId); + await requireOrgScope(user.id, organizationId); const { content } = c.req.valid("json"); @@ -833,6 +1255,75 @@ guardrails.openapi(testContent, async (c) => { }); }); +// POST /guardrails/projects/:projectId/test - Test content against project rules +const testProjectContent = createRoute({ + method: "post", + path: "/projects/{projectId}/test", + request: { + params: z.object({ + projectId: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + content: z.string(), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + passed: z.boolean(), + blocked: z.boolean(), + violations: z.array( + z.object({ + ruleId: z.string(), + ruleName: z.string(), + category: z.string(), + action: z.enum(["block", "redact", "warn", "allow"]), + matchedPattern: z.string().optional(), + matchedContent: z.string().optional(), + }), + ), + rulesChecked: z.number(), + }), + }, + }, + description: "Test result", + }, + }, +}); + +guardrails.openapi(testProjectContent, async (c) => { + const user = c.get("user"); + if (!user) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const { projectId } = c.req.param(); + const scope = await requireProjectScope(user.id, projectId); + + const { content } = c.req.valid("json"); + + const result = await checkGuardrails({ + organizationId: scope.organizationId, + projectId, + messages: [{ role: "user", content }], + }); + + return c.json({ + passed: result.passed, + blocked: result.blocked, + violations: result.violations, + rulesChecked: result.rulesChecked, + }); +}); + // GET /guardrails/system-rules - List all system rules with defaults const listSystemRules = createRoute({ method: "get", diff --git a/apps/docs/content/features/guardrails.mdx b/apps/docs/content/features/guardrails.mdx index 40232e5536..0b9ad6d080 100644 --- a/apps/docs/content/features/guardrails.mdx +++ b/apps/docs/content/features/guardrails.mdx @@ -25,6 +25,14 @@ Guardrails run on every API request, scanning message content for: When a violation is detected, you control what happens: block the request, redact the content, or log a warning. +## Organization and Project Scopes + +Guardrails are configured for the whole organization by default, and every project inherits that configuration. + +A project can opt out and define its own guardrails instead — useful when one application needs stricter or looser rules than the rest of the organization. Turn off **Use organization guardrails** in the project's Guardrails settings, and that project's configuration and custom rules fully replace the organization's for its traffic. The organization Guardrails page lists every project that currently overrides it. + +Only organization owners and admins can change guardrails, at either scope. + ## System Rules Built-in rules protect against common threats: diff --git a/apps/docs/content/learn/guardrails.mdx b/apps/docs/content/learn/guardrails.mdx index 2f70fc98ec..284417034a 100644 --- a/apps/docs/content/learn/guardrails.mdx +++ b/apps/docs/content/learn/guardrails.mdx @@ -20,6 +20,12 @@ The Guardrails page lets you configure content safety rules that automatically s A global toggle at the top enables or disables all guardrails for your organization. Click **Save Changes** to apply. +## Organization vs. Project + +The same page exists in each project's settings. By default a project uses the organization guardrails, and the page shows them read-only. + +Turn off **Use organization guardrails** on the project page to give that project its own configuration and custom rules, which then replace the organization's for that project's traffic. The organization page lists any projects that override it, so it is always clear when these settings do not apply everywhere. + ## System Rules Six built-in rules with individual enable/disable toggles: diff --git a/apps/gateway/src/chat/chat.ts b/apps/gateway/src/chat/chat.ts index 88a4d33225..72206b63a0 100644 --- a/apps/gateway/src/chat/chat.ts +++ b/apps/gateway/src/chat/chat.ts @@ -2575,6 +2575,7 @@ chat.openapi(completions, async (c) => { if (organization.plan === "enterprise") { guardrailResult = await checkGuardrails({ organizationId: project.organizationId, + projectId: project.id, messages: messages as Parameters[0]["messages"], }); diff --git a/apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/guardrails/page.tsx b/apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/guardrails/page.tsx new file mode 100644 index 0000000000..0dc62b54cd --- /dev/null +++ b/apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/guardrails/page.tsx @@ -0,0 +1,24 @@ +import { GuardrailsSettings } from "@/components/guardrails/guardrails-settings"; + +export default async function ProjectGuardrailsPage({ + params, +}: { + params: Promise<{ orgId: string; projectId: string }>; +}) { + const { orgId, projectId } = await params; + + return ( +
+
+
+

+ Guardrails +

+
+ +
+
+ ); +} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/contact-sales-card.tsx b/apps/ui/src/app/dashboard/[orgId]/org/guardrails/contact-sales-card.tsx deleted file mode 100644 index d3f8f891fd..0000000000 --- a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/contact-sales-card.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { CheckCircle, Mail } from "lucide-react"; - -import { Button } from "@/lib/components/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/lib/components/card"; - -export function ContactSalesCard() { - return ( -
-
-
-

- Guardrails -

-
- - - - Enterprise Feature - - Guardrails are available on the Enterprise plan - - - -

- Protect your LLM applications with enterprise-grade content safety - controls. Automatically detect and block prompt injections, - jailbreak attempts, and sensitive data exposure. -

- -
-

What's included:

-
    -
  • - - Prompt injection and jailbreak detection -
  • -
  • - - PII detection with automatic redaction -
  • -
  • - - Secrets and credentials scanning -
  • -
  • - - Custom blocked terms and regex patterns -
  • -
  • - - Topic restriction policies -
  • -
  • - - Real-time security event monitoring -
  • -
-
- - -
-
-
-
- ); -} diff --git a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/guardrails-client.tsx b/apps/ui/src/app/dashboard/[orgId]/org/guardrails/guardrails-client.tsx deleted file mode 100644 index 7296e86bea..0000000000 --- a/apps/ui/src/app/dashboard/[orgId]/org/guardrails/guardrails-client.tsx +++ /dev/null @@ -1,790 +0,0 @@ -"use client"; - -import { Plus, Trash2, Save, X } from "lucide-react"; -import { useParams } from "next/navigation"; -import { useState, useEffect, useCallback } from "react"; - -import { useDashboardNavigation } from "@/hooks/useDashboardNavigation"; -import { useTeamMembers } from "@/hooks/useTeam"; -import { useUser } from "@/hooks/useUser"; -import { Badge } from "@/lib/components/badge"; -import { Button } from "@/lib/components/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/lib/components/card"; -import { Input } from "@/lib/components/input"; -import { Label } from "@/lib/components/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/lib/components/select"; -import { Switch } from "@/lib/components/switch"; -import { useFetchClient } from "@/lib/fetch-client"; - -import { ContactSalesCard } from "./contact-sales-card"; - -interface SystemRuleConfig { - enabled: boolean; - action: "block" | "redact" | "warn" | "allow"; -} - -interface SystemRulesConfig { - prompt_injection: SystemRuleConfig; - jailbreak: SystemRuleConfig; - pii_detection: SystemRuleConfig; - secrets: SystemRuleConfig; - file_types: SystemRuleConfig; - document_leakage: SystemRuleConfig; -} - -interface GuardrailConfig { - id: string; - enabled: boolean; - systemRules: SystemRulesConfig; - maxFileSizeMb: number; - allowedFileTypes: string[]; - piiAction: "block" | "redact" | "warn" | "allow"; -} - -interface CustomRule { - id: string; - name: string; - type: "blocked_terms" | "custom_regex" | "topic_restriction"; - enabled: boolean; - priority: number; - action: "block" | "redact" | "warn" | "allow"; - config: Record; -} - -const SYSTEM_RULES = [ - { - id: "prompt_injection", - name: "Prompt Injection Detection", - description: - "Detect attempts to override system instructions or inject malicious prompts", - }, - { - id: "jailbreak", - name: "Jailbreak Prevention", - description: "Block attempts to bypass AI safety measures", - }, - { - id: "pii_detection", - name: "PII Detection", - description: - "Detect and optionally redact personal identifiable information", - }, - { - id: "secrets", - name: "Secrets Detection", - description: "Detect API keys, passwords, and other credentials", - }, - { - id: "file_types", - name: "File Type Restrictions", - description: "Restrict uploads to allowed file types", - }, - { - id: "document_leakage", - name: "Document Leakage Prevention", - description: "Prevent exposure of confidential document content", - }, -] as const; - -const DEFAULT_CONFIG: GuardrailConfig = { - id: "", - enabled: false, - systemRules: { - prompt_injection: { enabled: true, action: "block" }, - jailbreak: { enabled: true, action: "block" }, - pii_detection: { enabled: true, action: "redact" }, - secrets: { enabled: true, action: "block" }, - file_types: { enabled: true, action: "block" }, - document_leakage: { enabled: false, action: "warn" }, - }, - maxFileSizeMb: 10, - allowedFileTypes: ["pdf", "txt", "md", "csv", "json", "xml"], - piiAction: "redact", -}; - -export function GuardrailsClient() { - const params = useParams(); - const organizationId = params.orgId as string; - const fetchClient = useFetchClient(); - const { selectedOrganization } = useDashboardNavigation(); - const { user } = useUser(); - const { data: teamData, isLoading: isLoadingTeam } = - useTeamMembers(organizationId); - - const currentUserRole = teamData?.members.find( - (member) => member.userId === user?.id, - )?.role; - - const [config, setConfig] = useState(null); - const [customRules, setCustomRules] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - // Edit state for custom rules - const [showAddRule, setShowAddRule] = useState(false); - const [newRule, setNewRule] = useState({ - name: "", - type: "blocked_terms" as - "blocked_terms" | "custom_regex" | "topic_restriction", - action: "block" as "block" | "redact" | "warn" | "allow", - terms: "", - pattern: "", - topics: "", - }); - - const canManageGuardrails = - selectedOrganization?.plan === "enterprise" && - (currentUserRole === "owner" || currentUserRole === "admin"); - - const fetchConfig = useCallback(async () => { - setIsLoading(true); - setError(null); - // allSettled so a fulfilled response is still applied when the other - // request fails. - const [configResult, rulesResult] = await Promise.allSettled([ - fetchClient.GET("/guardrails/config/{organizationId}", { - params: { path: { organizationId } }, - }), - fetchClient.GET("/guardrails/rules/{organizationId}", { - params: { path: { organizationId } }, - }), - ]); - - // openapi-fetch resolves non-2xx responses as { error }, so checking for a - // rejection alone would only catch network-level failures. - const configResponse = - configResult.status === "fulfilled" ? configResult.value : null; - const rulesResponse = - rulesResult.status === "fulfilled" ? rulesResult.value : null; - - if (configResponse && !configResponse.error) { - // An org with no config yet gets a 200 with a null body — only then do - // the defaults apply. Falling back to them after an HTTP error would - // show guardrails as disabled and let the next save overwrite the - // org's real configuration. - setConfig( - (configResponse.data as unknown as GuardrailConfig | null) ?? - DEFAULT_CONFIG, - ); - } - - if (rulesResponse && !rulesResponse.error) { - setCustomRules( - (rulesResponse.data as { rules: CustomRule[] } | undefined)?.rules ?? - [], - ); - } - - if ( - !configResponse || - configResponse.error || - !rulesResponse || - rulesResponse.error - ) { - setError("Failed to load guardrails configuration"); - } - setIsLoading(false); - }, [fetchClient, organizationId]); - - useEffect(() => { - if (canManageGuardrails) { - void fetchConfig(); - } else { - setIsLoading(false); - } - }, [canManageGuardrails, fetchConfig]); - - const saveConfig = async () => { - if (!config) { - return; - } - - try { - setIsSaving(true); - setError(null); - setSuccess(null); - - await fetchClient.PUT("/guardrails/config/{organizationId}", { - params: { path: { organizationId } }, - body: { - enabled: config.enabled, - systemRules: config.systemRules, - maxFileSizeMb: config.maxFileSizeMb, - allowedFileTypes: config.allowedFileTypes, - piiAction: config.piiAction, - }, - }); - - setSuccess("Configuration saved successfully"); - setTimeout(() => setSuccess(null), 3000); - } catch { - setError("Failed to save configuration"); - } finally { - setIsSaving(false); - } - }; - - const updateSystemRule = ( - ruleId: keyof SystemRulesConfig, - field: keyof SystemRuleConfig, - value: boolean | string, - ) => { - if (!config) { - return; - } - - setConfig({ - ...config, - systemRules: { - ...config.systemRules, - [ruleId]: { - ...config.systemRules[ruleId], - [field]: value, - }, - }, - }); - }; - - const addCustomRule = async () => { - try { - setError(null); - - let ruleConfig: - | { - type: "blocked_terms"; - terms: string[]; - matchType: "exact" | "contains" | "regex"; - caseSensitive: boolean; - } - | { type: "custom_regex"; pattern: string } - | { type: "topic_restriction"; blockedTopics: string[] }; - - if (newRule.type === "blocked_terms") { - ruleConfig = { - type: "blocked_terms", - terms: newRule.terms.split("\n").filter((t) => t.trim()), - matchType: "contains", - caseSensitive: false, - }; - } else if (newRule.type === "custom_regex") { - ruleConfig = { - type: "custom_regex", - pattern: newRule.pattern, - }; - } else { - ruleConfig = { - type: "topic_restriction", - blockedTopics: newRule.topics.split("\n").filter((t) => t.trim()), - }; - } - - const response = await fetchClient.POST( - "/guardrails/rules/{organizationId}", - { - params: { path: { organizationId } }, - body: { - name: newRule.name, - type: newRule.type, - action: newRule.action, - config: ruleConfig, - enabled: true, - priority: customRules.length + 1, - }, - }, - ); - - if (response.data) { - setCustomRules([...customRules, response.data as CustomRule]); - setShowAddRule(false); - setNewRule({ - name: "", - type: "blocked_terms", - action: "block", - terms: "", - pattern: "", - topics: "", - }); - } - } catch { - setError("Failed to add rule"); - } - }; - - const deleteCustomRule = async (ruleId: string) => { - try { - await fetchClient.DELETE("/guardrails/rules/{organizationId}/{ruleId}", { - params: { path: { organizationId, ruleId } }, - }); - - setCustomRules(customRules.filter((r) => r.id !== ruleId)); - } catch { - setError("Failed to delete rule"); - } - }; - - const toggleRuleEnabled = async (rule: CustomRule) => { - try { - await fetchClient.PATCH("/guardrails/rules/{organizationId}/{ruleId}", { - params: { path: { organizationId, ruleId: rule.id } }, - body: { enabled: !rule.enabled }, - }); - - setCustomRules( - customRules.map((r) => - r.id === rule.id ? { ...r, enabled: !r.enabled } : r, - ), - ); - } catch { - setError("Failed to update rule"); - } - }; - - if (selectedOrganization?.plan !== "enterprise") { - return ; - } - - if (isLoadingTeam || !currentUserRole) { - return ( -
-
-
- ); - } - - if (currentUserRole !== "owner" && currentUserRole !== "admin") { - return ( - - - Access Denied - - Only organization owners and admins can manage guardrails. - - - - ); - } - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
- {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - {/* Main Toggle */} - - -
-
- Guardrails - - Configure content safety rules for your LLM applications - -
- {config && ( -
-
- - setConfig({ ...config, enabled }) - } - /> - -
- -
- )} -
-
-
- - {config && ( -
- {/* System Rules */} - - - System Rules - - Built-in security rules powered by pattern matching - - - - {SYSTEM_RULES.map((rule) => { - const ruleConfig = - config.systemRules[rule.id as keyof SystemRulesConfig]; - return ( -
-
- - updateSystemRule( - rule.id as keyof SystemRulesConfig, - "enabled", - enabled, - ) - } - /> -
-
{rule.name}
-
- {rule.description} -
-
-
- {ruleConfig?.enabled && ( - - )} -
- ); - })} -
-
- - {/* File Restrictions */} - - - File Restrictions - - Configure allowed file types and size limits - - - -
- - - setConfig({ - ...config, - maxFileSizeMb: parseInt(e.target.value, 10) || 10, - }) - } - className="w-32" - /> -
-
- -
- {config.allowedFileTypes.map((type) => ( - - {type} - {config.enabled && ( - - )} - - ))} -
-
- { - if (e.key === "Enter") { - const input = e.currentTarget; - const value = input.value.trim().toLowerCase(); - if (value && !config.allowedFileTypes.includes(value)) { - setConfig({ - ...config, - allowedFileTypes: [ - ...config.allowedFileTypes, - value, - ], - }); - input.value = ""; - } - } - }} - /> -
-
-
-
- - {/* Custom Rules */} - - -
-
- Custom Rules - - Create custom content filtering rules - -
- -
-
- - {showAddRule && config.enabled && ( -
-
-

New Rule

- -
-
-
- - - setNewRule({ ...newRule, name: e.target.value }) - } - placeholder="e.g., Block competitors" - /> -
-
- - -
-
-
- - -
- {newRule.type === "blocked_terms" && ( -
- -