diff --git a/api/vendor/agentfox-0.3.1-py3-none-any.whl b/api/vendor/agentfox-0.3.1-py3-none-any.whl index 36ac0c95..10582b0a 100644 Binary files a/api/vendor/agentfox-0.3.1-py3-none-any.whl and b/api/vendor/agentfox-0.3.1-py3-none-any.whl differ diff --git a/dashboard/app/(product)/app/agents/[slug]/page.tsx b/dashboard/app/(product)/app/agents/[slug]/page.tsx index 2a419df3..1da020d3 100644 --- a/dashboard/app/(product)/app/agents/[slug]/page.tsx +++ b/dashboard/app/(product)/app/agents/[slug]/page.tsx @@ -88,12 +88,17 @@ export default async function AgentDetail({ params, searchParams }: { params: Pr } actions={ + <> + + Protect +
-
+ } /> - + {state !== "killed" && (
diff --git a/dashboard/app/(product)/app/agents/[slug]/protect/page.tsx b/dashboard/app/(product)/app/agents/[slug]/protect/page.tsx new file mode 100644 index 00000000..e349f508 --- /dev/null +++ b/dashboard/app/(product)/app/agents/[slug]/protect/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { appPageMetadata } from "@/lib/site"; +import { safeApi } from "@/lib/product/api"; +import { Card, Empty, Header } from "@/components/kit"; +import { ProtectWizard } from "@/components/product/agent/ProtectWizard"; + +export const metadata: Metadata = appPageMetadata("Protect agent"); +export const dynamic = "force-dynamic"; + +export default async function Protect({ params }: { params: Promise<{ slug: string }> }) { + const { slug: raw } = await params; + const slug = decodeURIComponent(raw); + const [state, tools] = await Promise.all([ + safeApi(`/api/agents/${encodeURIComponent(slug)}/protection`, null), + safeApi("/api/tools", { tools: [] }), + ]); + return ( + <> +
+ + {state ? ( + !t.key.startsWith("redteam.")).map((t: any) => ({ key: t.key, name: t.name }))} + /> + ) : ( + This agent could not be loaded. + )} + + + ); +} diff --git a/dashboard/app/(product)/app/policies/page.tsx b/dashboard/app/(product)/app/policies/page.tsx index 74cd820a..8b12e168 100644 --- a/dashboard/app/(product)/app/policies/page.tsx +++ b/dashboard/app/(product)/app/policies/page.tsx @@ -23,6 +23,7 @@ import { Detectors } from "@/components/product/policies/Detectors"; import { ImportGuard } from "@/components/product/policies/ImportGuard"; import { LibraryPacks } from "@/components/product/policies/LibraryPacks"; import { PackMode } from "@/components/product/policies/PackMode"; +import { WorkspaceCode } from "@/components/product/policies/WorkspaceCode"; import { LegacyJudgmentTab, LegacyPacksTab, LegacyTuningTab } from "@/components/product/policies/legacy"; import { metricsQs, runsHref } from "@/lib/product/observe"; import { loadRules, ruleMode, type RuleInfo } from "@/lib/product/rules"; @@ -38,6 +39,7 @@ const TABS = [ { key: "performance", label: "Performance" }, { key: "library", label: "Library" }, { key: "changes", label: "Changes" }, + { key: "code", label: "Code" }, { key: "advanced", label: "Advanced" }, ]; @@ -68,6 +70,7 @@ export default async function Policies({ searchParams }: { searchParams: Promise {tab === "performance" && } {tab === "library" && } {tab === "changes" && } + {tab === "code" && } {tab === "advanced" && } ); @@ -345,6 +348,17 @@ async function LibraryTab({ sec }: { sec?: string }) { ); } +// --- Code -------------------------------------------------------------------- + +async function CodeTab() { + const exported = await safeApi("/api/workspace/export", { source: "" }); + return ( + + + + ); +} + // --- Changes ----------------------------------------------------------------- async function ChangesTab() { diff --git a/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx b/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx index 20d4cc70..a7f6d605 100644 --- a/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx +++ b/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx @@ -1,10 +1,11 @@ import type { Metadata } from "next"; import Link from "next/link"; import { appPageMetadata } from "@/lib/site"; -import { safeApi } from "@/lib/product/api"; +import { post, safeApi } from "@/lib/product/api"; import { ActionPill, BarList, Card, Empty, Grid, Header, Kpi, ModePill, Pill, Tabs, ago, href, num } from "@/components/kit"; import { FilterBar } from "@/components/kit/FilterBar"; import { RunsTable } from "@/components/kit/RunsTable"; +import { RuleTests } from "@/components/product/policies/RuleTests"; import { RuleTuner } from "@/components/product/policies/RuleTuner"; import { RANGE_DAYS, metricsQs, runsHref } from "@/lib/product/observe"; import { loadRules, ruleMode } from "@/lib/product/rules"; @@ -15,7 +16,8 @@ export const dynamic = "force-dynamic"; const TABS = [ { key: "overview", label: "Overview" }, - { key: "examples", label: "Examples" }, + { key: "examples", label: "Hits" }, + { key: "tests", label: "Tests" }, { key: "tune", label: "Tune" }, { key: "history", label: "History" }, ]; @@ -101,6 +103,12 @@ export default async function RulePage({ params, searchParams }: { params: Promi {tab === "examples" && } + {tab === "tests" && ( + + {rule ? : Not in any installed pack.} + + )} + {tab === "tune" && ( {rule ? : Not in any installed pack.} @@ -149,3 +157,14 @@ async function Examples({ ruleId, range }: { ruleId: string; range: keyof typeof ); } + +async function Tests({ ruleId, pack, agents }: { ruleId: string; pack?: string; agents: string[] }) { + const [checked, all] = await Promise.all([ + pack + ? post(`/api/rules/${encodeURIComponent(ruleId)}/examples/check`, { policy: pack }).catch(() => ({ results: [] })) + : Promise.resolve({ results: [] }), + safeApi("/api/agents", { agents: [] }), + ]); + const slugs = Array.from(new Set([...agents, ...(all.agents || []).filter((a: any) => a.status !== "draft").map((a: any) => a.slug)])); + return ; +} diff --git a/dashboard/app/api/agents/[slug]/protection/preview/route.ts b/dashboard/app/api/agents/[slug]/protection/preview/route.ts new file mode 100644 index 00000000..60797ab3 --- /dev/null +++ b/dashboard/app/api/agents/[slug]/protection/preview/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; +import { proxyJson } from "@/lib/product/proxy"; +import { protectionSetup } from "@/lib/product/protection"; + +export const dynamic = "force-dynamic"; + +/** Replay the agent's last week against a setup. Saves nothing. */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + return proxyJson(`/api/agents/${encodeURIComponent(slug)}/protection/preview`, "POST", protectionSetup(await req.json())); +} diff --git a/dashboard/app/api/agents/[slug]/protection/route.ts b/dashboard/app/api/agents/[slug]/protection/route.ts new file mode 100644 index 00000000..c1deebbb --- /dev/null +++ b/dashboard/app/api/agents/[slug]/protection/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server"; +import { protectionSetup } from "@/lib/product/protection"; +import { proxyJson } from "@/lib/product/proxy"; + +export const dynamic = "force-dynamic"; + +/** Save an agent's protections, words, topics and blocked message. */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + return proxyJson(`/api/agents/${encodeURIComponent(slug)}/protection`, "POST", protectionSetup(await req.json())); +} + diff --git a/dashboard/app/api/policies/[key]/rules/[ruleId]/route.ts b/dashboard/app/api/policies/[key]/rules/[ruleId]/route.ts index e354c121..2961c0d0 100644 --- a/dashboard/app/api/policies/[key]/rules/[ruleId]/route.ts +++ b/dashboard/app/api/policies/[key]/rules/[ruleId]/route.ts @@ -29,5 +29,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ key persist: Boolean(version.needs_simulation), }); const simulation = sim.ok ? await sim.json() : null; - return NextResponse.json({ ...version, simulation }); + // The rule's saved tests, re-checked against exactly this proposed version. + const checked = await proxyJson(`/api/rules/${encodeURIComponent(ruleId)}/examples/check`, "POST", { policy: key, body: version.body }); + const tests = checked.ok ? await checked.json() : null; + return NextResponse.json({ ...version, simulation, tests }); } diff --git a/dashboard/app/api/rules/[ruleId]/examples/[id]/route.ts b/dashboard/app/api/rules/[ruleId]/examples/[id]/route.ts new file mode 100644 index 00000000..5fcb0935 --- /dev/null +++ b/dashboard/app/api/rules/[ruleId]/examples/[id]/route.ts @@ -0,0 +1,9 @@ +import { proxyJson } from "@/lib/product/proxy"; + +export const dynamic = "force-dynamic"; + +/** Delete one saved test from a rule. */ +export async function DELETE(_req: Request, { params }: { params: Promise<{ ruleId: string; id: string }> }) { + const { ruleId, id } = await params; + return proxyJson(`/api/rules/${encodeURIComponent(ruleId)}/examples/${encodeURIComponent(id)}`, "DELETE"); +} diff --git a/dashboard/app/api/rules/[ruleId]/examples/route.ts b/dashboard/app/api/rules/[ruleId]/examples/route.ts new file mode 100644 index 00000000..cc0f0da7 --- /dev/null +++ b/dashboard/app/api/rules/[ruleId]/examples/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { proxyJson } from "@/lib/product/proxy"; + +export const dynamic = "force-dynamic"; + +/** + * Save a test for a rule: run the text through the playground for the chosen agent + * (so it is recorded, but not counted as production traffic), then keep that decision + * with whether the rule should fire on it. + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ ruleId: string }> }) { + const { ruleId } = await params; + const { text, surface, agent, fires } = await req.json(); + if (!agent || !String(text || "").trim()) return NextResponse.json({ detail: "choose an agent and enter text" }, { status: 400 }); + const ran = await proxyJson(surface === "output" ? "/v1/guard/output" : "/v1/guard/input", "POST", { + agent: String(agent), + content: String(text), + surface: surface === "output" ? "output" : "input", + environment: "playground", + }); + if (!ran.ok) return ran; + const { decision_id } = await ran.json(); + return proxyJson(`/api/rules/${encodeURIComponent(ruleId)}/examples`, "POST", { + decision_id, + fires: Boolean(fires), + sample: String(text).slice(0, 2000), + }); +} diff --git a/dashboard/app/api/workspace/apply/route.ts b/dashboard/app/api/workspace/apply/route.ts new file mode 100644 index 00000000..559f8e85 --- /dev/null +++ b/dashboard/app/api/workspace/apply/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyJson } from "@/lib/product/proxy"; + +export const dynamic = "force-dynamic"; + +/** Apply a workspace file. Anything it leaves out is left alone. */ +export async function POST(req: NextRequest) { + const { source } = await req.json(); + return proxyJson("/api/workspace/apply", "POST", { source: String(source || "") }); +} diff --git a/dashboard/app/api/workspace/plan/route.ts b/dashboard/app/api/workspace/plan/route.ts new file mode 100644 index 00000000..900eff25 --- /dev/null +++ b/dashboard/app/api/workspace/plan/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyJson } from "@/lib/product/proxy"; + +export const dynamic = "force-dynamic"; + +/** What applying a workspace file would change. Saves nothing. */ +export async function POST(req: NextRequest) { + const { source } = await req.json(); + return proxyJson("/api/workspace/plan", "POST", { source: String(source || "") }); +} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index d61e51d8..3797cc03 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -3192,6 +3192,15 @@ textarea.k-input { height: auto; padding: 8px 11px; line-height: 1.5; resize: ve .k-preview-nums { display: flex; gap: 18px; flex-wrap: wrap; font-size: var(--t-small); } .k-preview-nums strong { font-variant-numeric: tabular-nums; } .k-plain { margin: 0; padding-left: 18px; font-size: var(--t-small); display: flex; flex-direction: column; gap: 2px; } +.k-steps { display: flex; gap: 4px; list-style: none; margin: 0 0 6px; padding: 0; overflow-x: auto; scrollbar-width: none; } +.k-steps button { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 6px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--panel); color: var(--muted); font-size: var(--t-small); white-space: nowrap; cursor: pointer; } +.k-steps button:disabled { cursor: default; opacity: .5; } +.k-steps .k-step-n { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--border); color: var(--text); font-size: var(--t-micro); } +.k-steps button.active { color: var(--text); border-color: var(--text); } +.k-steps button.active .k-step-n { background: var(--text); color: var(--panel); } +.k-steps button.done .k-step-n { background: var(--viz-allowed); color: var(--panel); } +.k-form > .k-seg { align-self: flex-start; } +.k-code { margin: 0; max-height: 520px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--r-2); background: var(--panel-2); font-size: var(--t-small); line-height: 1.5; } .k-details { margin-top: 14px; } .k-details > summary { cursor: pointer; color: var(--muted); font-size: var(--t-small); margin-bottom: 10px; } h2.k-section { font-size: var(--t-small); font-weight: 620; margin: 22px 0 10px; } diff --git a/dashboard/components/product/agent/ProtectWizard.tsx b/dashboard/components/product/agent/ProtectWizard.tsx new file mode 100644 index 00000000..f7883f66 --- /dev/null +++ b/dashboard/components/product/agent/ProtectWizard.tsx @@ -0,0 +1,249 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { callJson } from "@/components/kit/Act"; +import { TryIt } from "@/components/product/test/TryIt"; + +type Protection = { + key: string; + title: string; + level: string; + inherited: string | null; + inherited_mode: string | null; + graded: boolean; + needs: string | null; + needs_installed?: boolean; + default: string; +}; + +export type ProtectionState = { + agent: string; + mode: string | null; + message: string; + levels: Record; + protections: Protection[]; + words: string[]; + avoid: string; + allowed: string; +}; + +const STEPS = ["Protections", "Words & topics", "Message", "Review", "Test"] as const; +const GRADES = ["off", "low", "medium", "high"]; +const LABEL: Record = { off: "Off", on: "On", low: "Low", medium: "Medium", high: "High" }; +const RANK: Record = { off: 0, low: 1, on: 1, medium: 2, high: 3 }; + +/** + * Protect one agent, start to finish: what to stop and how strictly, its own words + * and topics, what its users are told, a replay of its last week, then a live test. + * Everything new starts watching. + */ +export function ProtectWizard({ + initial, + tools, +}: { + initial: ProtectionState; + tools: { key: string; name?: string }[]; +}) { + const router = useRouter(); + const fresh = initial.protections.every((p) => p.level === "off"); + const [step, setStep] = useState(0); + const [choices, setChoices] = useState>(() => + Object.fromEntries(initial.protections.map((p) => [p.key, fresh ? startLevel(p) : p.level])), + ); + const [words, setWords] = useState(initial.words.join("\n")); + const [avoid, setAvoid] = useState(initial.avoid); + const [allowed, setAllowed] = useState(initial.allowed); + const [message, setMessage] = useState(initial.message); + const [preview, setPreview] = useState(null); + const [saved, setSaved] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const payload = () => ({ + protections: choices, + message, + words: words.split(/\n|,/).map((w) => w.trim()).filter(Boolean), + avoid, + allowed, + }); + const base = `/api/agents/${encodeURIComponent(initial.agent)}/protection`; + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(""); + try { + await fn(); + } catch (e: any) { + setError(e.message); + } finally { + setBusy(false); + } + }; + + const go = (i: number) => { + setStep(i); + if (STEPS[i] === "Review" && !saved) run(async () => setPreview(await callJson(`${base}/preview`, "POST", payload()))); + }; + + const save = () => + run(async () => { + setSaved(await callJson(base, "POST", payload())); + router.refresh(); + setStep(STEPS.indexOf("Test")); + }); + + return ( +
+
    + {STEPS.map((s, i) => ( +
  1. + +
  2. + ))} +
+ + {STEPS[step] === "Protections" && ( + + + {initial.protections.map((p) => { + const options = p.graded ? GRADES : ["off", "on"]; + const floor = p.inherited ? RANK[p.inherited] : 0; + return ( + + + + + ); + })} + +
+ {p.title} + {p.inherited && ( + + {LABEL[p.inherited]} for every agent{p.inherited_mode === "enforce" ? "" : " · watching"} + + )} + {p.needs && p.needs_installed === false && choices[p.key] !== "off" && Model not installed here} + +
+ {options.map((o) => ( + + ))} +
+
+ )} + + {STEPS[step] === "Words & topics" && ( + <> +
+ +