Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified api/vendor/agentfox-0.3.1-py3-none-any.whl
Binary file not shown.
2 changes: 2 additions & 0 deletions dashboard/app/(product)/app/agents/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { AgentMap } from "@/components/product/AgentMap";
import { AccessEditor } from "@/components/product/agent/AccessEditor";
import { RANGE_DAYS, metricsQs, runsHref, verdictsFor, type Filters } from "@/lib/product/observe";
import { CATEGORIES, categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab";
import { ensureRange } from "@/lib/product/range";

export const metadata: Metadata = appPageMetadata("Agent");
export const dynamic = "force-dynamic";
Expand All @@ -52,6 +53,7 @@ export default async function AgentDetail({ params, searchParams }: { params: Pr
const sp = await searchParams;
const requested = LEGACY[sp.tab || ""] || sp.tab;
const tab = TABS.some((t) => t.key === requested) ? requested! : "overview";
if (["overview", "rules", "activity"].includes(tab)) await ensureRange(`/app/agents/${encodeURIComponent(slug)}`, sp, slug);
const f: Filters = { range: rangeOf(sp.range), agent: slug };

let posture: any;
Expand Down
7 changes: 5 additions & 2 deletions dashboard/app/(product)/app/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { appPageMetadata } from "@/lib/site";
import { api, apiErrorProps, safeApi } from "@/lib/product/api";
import { activeRange } from "@/lib/product/range";
import { RANGE_WORDS } from "@/lib/product/vocab";
import { ApiDown } from "@/components/ui";
import { Card, Dot, Empty, Header, Pill, StackBar, Tabs, ago, href, num, pctOf } from "@/components/kit";
import { Modal } from "@/components/product/Modal";
Expand All @@ -24,8 +26,9 @@ export default async function Agents({ searchParams }: { searchParams: Promise<S
</>
);
}
const { range } = await activeRange();
const [usage, findings] = await Promise.all([
safeApi<any>("/api/metrics/breakdown?dim=agent&range=7d", { rows: [] }),
safeApi<any>(`/api/metrics/breakdown?dim=agent&range=${range}`, { rows: [] }),
safeApi<any>("/api/findings?status=open", { findings: [] }),
]);

Expand Down Expand Up @@ -88,7 +91,7 @@ export default async function Agents({ searchParams }: { searchParams: Promise<S
<th>Agent</th>
<th>Owner</th>
<th className="tight">Risk</th>
<th style={{ width: "18%" }}>Last 7 days</th>
<th style={{ width: "18%" }}>Last {RANGE_WORDS[range]}</th>
<th className="num">Requests</th>
<th className="num">Stopped</th>
<th className="num">Issues</th>
Expand Down
25 changes: 23 additions & 2 deletions dashboard/app/(product)/app/findings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default async function Issues({ searchParams }: { searchParams: Promise<S
safeApi<any>("/api/agents", { agents: [] }),
Promise.all(STATUSES.map((s) => safeApi<any>(`/api/findings?status=${s.key}&limit=500${sp.agent ? `&agent=${sp.agent}` : ""}`, { findings: [] }))),
]);
const rows = [...(data.findings || [])].sort(
const rows = group(data.findings || []).sort(
(a, b) => severityRank(a.severity) - severityRank(b.severity) || String(b.last_seen_at).localeCompare(String(a.last_seen_at)),
);
const keep = { agent: sp.agent, severity: sp.severity };
Expand All @@ -55,7 +55,7 @@ export default async function Issues({ searchParams }: { searchParams: Promise<S
items={STATUSES.map((s, i) => ({
key: s.key,
label: s.label,
count: counts[i].findings?.length,
count: group(counts[i].findings || []).length,
href: href("/app/findings", { ...keep, status: s.key === "open" ? undefined : s.key }),
}))}
/>
Expand Down Expand Up @@ -107,3 +107,24 @@ export default async function Issues({ searchParams }: { searchParams: Promise<S
</>
);
}

/**
* One row per problem. Issues raised before findings carried a fingerprint (Sept
* 2026) were never merged, so the same problem can appear as many rows; they are
* counted together here, linking to the most recent.
*/
function group(findings: any[]): any[] {
const byKey = new Map<string, any>();
for (const f of findings) {
const key = [f.type, f.title, f.agent_slug || "", f.subject_type || "", f.subject_id || "", f.status].join("|");
const seen = byKey.get(key);
const at = String(f.last_seen_at || f.created_at || "");
if (!seen) {
byKey.set(key, { ...f, occurrences: f.occurrences || 1 });
continue;
}
seen.occurrences += f.occurrences || 1;
if (at > String(seen.last_seen_at || seen.created_at || "")) Object.assign(seen, { id: f.id, last_seen_at: at });
}
return Array.from(byKey.values());
}
2 changes: 2 additions & 0 deletions dashboard/app/(product)/app/observe/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@/components/kit";
import { FilterBar } from "@/components/kit/FilterBar";
import { filtersFrom, metricsQs, observeTabs, runsHref, type Filters } from "@/lib/product/observe";
import { ensureRange } from "@/lib/product/range";
import { CATEGORIES, categoryLabel, detectorName, dimLabel, ruleCategory, ruleTitle } from "@/lib/product/vocab";

export const metadata: Metadata = appPageMetadata("Observe");
Expand All @@ -30,6 +31,7 @@ type SP = Record<string, string | undefined>;

export default async function Observe({ searchParams }: { searchParams: Promise<SP> }) {
const sp = await searchParams;
await ensureRange("/app/observe", sp);
const f = filtersFrom(sp);
const tab = ["breaks", "security", "review", "cost"].includes(sp.tab || "") ? sp.tab! : "overview";

Expand Down
19 changes: 11 additions & 8 deletions dashboard/app/(product)/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import type { Metadata } from "next";
import Link from "next/link";
import { appPageMetadata } from "@/lib/site";
import { safeApi } from "@/lib/product/api";
import { Card, Dot, Empty, Grid, Header, Kpi, Pill, SeverityPill, StackBar, TimeChart, ago, num, pctOf } from "@/components/kit";
import { Card, Dot, Empty, Grid, Header, Kpi, Pill, SeverityPill, StackBar, TimeChart, ago, href, num, pctOf } from "@/components/kit";
import { Countdown } from "@/components/product/Countdown";
import { SetupProgress } from "@/components/product/home/SetupProgress";
import { runsHref } from "@/lib/product/observe";
import { severityRank } from "@/lib/product/vocab";
import { activeRange } from "@/lib/product/range";
import { RANGE_WORDS, severityRank } from "@/lib/product/vocab";

// `appPageMetadata`, not a literal: it also clears the canonical URL, so this page
// does not declare itself a duplicate of the marketing home page.
Expand All @@ -21,11 +22,13 @@ type SP = { review_notice?: string; review_error?: string };
*/
export default async function Home({ searchParams }: { searchParams: Promise<SP> }) {
const sp = await searchParams;
const f = { range: "7d" as const };
// The week, or the smallest window that holds the latest traffic in a quiet workspace.
const { range, last } = await activeRange();
const f = { range };
const [onboarding, summary, byAgent, agents, approvals, findings, policies] = await Promise.all([
safeApi<any>("/api/onboarding", null),
safeApi<any>("/api/metrics/summary?range=7d", null),
safeApi<any>("/api/metrics/breakdown?dim=agent&range=7d", { rows: [] }),
safeApi<any>(`/api/metrics/summary?range=${range}`, null),
safeApi<any>(`/api/metrics/breakdown?dim=agent&range=${range}`, { rows: [] }),
safeApi<any>("/api/agents", { agents: [] }),
safeApi<any>("/api/approvals?status=pending", { approvals: [] }),
safeApi<any>("/api/findings?status=open&limit=200", { findings: [] }),
Expand All @@ -41,11 +44,11 @@ export default async function Home({ searchParams }: { searchParams: Promise<SP>
.sort((a: any, c: any) => severityRank(a.severity) - severityRank(c.severity))
.slice(0, 4);
const agentName: Record<string, string> = Object.fromEntries((agents.agents || []).map((a: any) => [a.id, a.name || a.slug]));
const watching = (policies.policies || []).filter((x: any) => x.mode === "observe");
const watching = (policies.policies || []).filter((x: any) => x.mode === "observe" && x.rules > 0);
const usage: Record<string, any> = Object.fromEntries((byAgent.rows || []).map((r: any) => [r.key, r]));
const live = (agents.agents || []).filter((a: any) => a.status !== "draft");
const max = Math.max(1, ...Object.values(usage).map((r: any) => r.requests));
const noTraffic = !t.requests && !p.requests;
const noTraffic = !last;

return (
<>
Expand Down Expand Up @@ -114,7 +117,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<SP>
) : (
<>
<Grid cols={4}>
<Kpi label="Requests · 7 days" value={num(t.requests)} current={t.requests} prev={p.requests} better="none" href="/app/observe" spark={b.map((x: any) => x.allowed + x.masked + x.held + x.blocked)} />
<Kpi label={`Requests · ${RANGE_WORDS[range]}`} value={num(t.requests)} current={t.requests} prev={p.requests} better="none" href={href("/app/observe", { range })} spark={b.map((x: any) => x.allowed + x.masked + x.held + x.blocked)} />
<Kpi label="Blocked" value={num(t.blocked)} current={t.blocked} prev={p.blocked} href={runsHref(f, { outcome: "blocked" })} spark={b.map((x: any) => x.blocked)} />
<Kpi label="Held for a person" value={num(t.held)} current={t.held} prev={p.held} href={runsHref(f, { outcome: "held" })} spark={b.map((x: any) => x.held)} />
<Kpi label="Errors" value={num(t.errors)} current={t.errors} prev={p.errors} tone={t.errors ? "bad" : undefined} href={runsHref(f, { errors: true })} />
Expand Down
5 changes: 4 additions & 1 deletion dashboard/app/(product)/app/policies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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";
import { ensureRange } from "@/lib/product/range";
import { CATEGORIES, categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab";

export const metadata: Metadata = appPageMetadata("Policies", "What your agents may do, and how well each rule is working.");
Expand Down Expand Up @@ -59,6 +60,7 @@ export default async function Policies({ searchParams }: { searchParams: Promise
const legacy = LEGACY[sp.tab || ""];
const tab = legacy?.tab || (TABS.some((t) => t.key === sp.tab) ? sp.tab! : "rules");
const sec = legacy?.sec || sp.sec;
if (tab === "rules" || tab === "performance") await ensureRange("/app/policies", sp);

return (
<>
Expand Down Expand Up @@ -110,7 +112,8 @@ async function RulesTab({ sp }: { sp: SP }) {
<Card title="Packs" flush>
<table className="k-table">
<tbody>
{packs.map((p) => (
{/* A pack with no rules (a framework profile with nothing in it yet) decides nothing. */}
{packs.filter((p) => p.rules.length > 0).map((p) => (
<tr key={p.key}>
<td>
<Link className="k-name" href={`/app/policies/${encodeURIComponent(p.key)}`}>{p.name}</Link>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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";
import { ensureRange } from "@/lib/product/range";
import { categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab";

export const metadata: Metadata = appPageMetadata("Rule");
Expand All @@ -29,6 +30,7 @@ export default async function RulePage({ params, searchParams }: { params: Promi
const ruleId = decodeURIComponent(raw);
const sp = await searchParams;
const tab = TABS.some((t) => t.key === sp.tab) ? sp.tab! : "overview";
if (tab === "overview" || tab === "examples") await ensureRange(`/app/policies/rules/${encodeURIComponent(ruleId)}`, sp);
const range = rangeOf(sp.range);

const custom = ruleId.startsWith("custom.");
Expand Down
2 changes: 2 additions & 0 deletions dashboard/app/(product)/app/traces/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Card, Header, Tabs, href, when } from "@/components/kit";
import { FilterBar } from "@/components/kit/FilterBar";
import { RunsTable } from "@/components/kit/RunsTable";
import { RANGE_DAYS, filtersFrom, keep, metricsQs, observeTabs, verdictsFor } from "@/lib/product/observe";
import { ensureRange } from "@/lib/product/range";
import { ruleTitle } from "@/lib/product/vocab";

export const metadata: Metadata = appPageMetadata("Runs");
Expand All @@ -24,6 +25,7 @@ const OUTCOME_CHIPS = [

export default async function Runs({ searchParams }: { searchParams: Promise<SP> }) {
const sp = await searchParams;
await ensureRange("/app/traces", sp);
const f = filtersFrom(sp);
const errorsOnly = sp.errors === "1" || sp.outcome === "errors";

Expand Down
2 changes: 1 addition & 1 deletion dashboard/components/kit/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function FilterBar({
type="button"
className={current === r.key ? "active" : ""}
aria-pressed={current === r.key}
onClick={() => set("range", r.key === defaultRange ? "" : r.key)}
onClick={() => set("range", r.key)}
>
{r.label}
</button>
Expand Down
8 changes: 7 additions & 1 deletion dashboard/components/kit/RunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ export function RunsTable({ runs, showAgent = true }: { runs: any[]; showAgent?:
</td>
<td className="tight">
<span className="k-pills">
<OutcomePill outcome={outcomeOf(t.verdict)} />
{t.checks === 0 ? (
<Pill tone="outline" title="This run reached AgentFox as telemetry only; no check ran on it.">
Not checked
</Pill>
) : (
<OutcomePill outcome={outcomeOf(t.verdict)} />
)}
{t.would_verdict && outcomeOf(t.verdict) === "allowed" && (
<Pill tone="outline" title="The rule is only watching. Enforced, this run would have been stopped.">
{WOULD[t.would_verdict]}
Expand Down
37 changes: 36 additions & 1 deletion dashboard/lib/generated/reference/api.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"generated_by": "scripts/gen/docs_reference.py",
"count": 239,
"count": 240,
"routes": [
{
"method": "GET",
Expand Down Expand Up @@ -5956,6 +5956,41 @@
],
"body": null
},
{
"method": "GET",
"path": "/api/metrics/activity",
"section": "metrics",
"audience": "operator",
"summary": "Activity",
"description": "",
"params": [
{
"name": "agent",
"in": "query",
"required": false,
"type": null
},
{
"name": "environment",
"in": "query",
"required": false,
"type": null
},
{
"name": "authorization",
"in": "header",
"required": false,
"type": null
},
{
"name": "x-agentfox-user",
"in": "header",
"required": false,
"type": null
}
],
"body": null
},
{
"method": "GET",
"path": "/api/metrics/breakdown",
Expand Down
4 changes: 3 additions & 1 deletion dashboard/lib/product/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export function metricsQs(f: Filters, extra: Record<string, string> = {}): strin

/** Filters carried from one view to the next, so switching tabs keeps them. */
export function keep(f: Filters): Record<string, string | undefined> {
return { range: f.range === "7d" ? undefined : f.range, agent: f.agent, env: f.env };
// Always explicit: a view with no range picks one from the data (`lib/product/range.ts`),
// so dropping "7d" here would send a reader who chose a week back to the data's window.
return { range: f.range, agent: f.agent, env: f.env };
}

export const OBSERVE_TABS = [
Expand Down
28 changes: 28 additions & 0 deletions dashboard/lib/product/range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { href } from "@/components/kit";
import { safeApi } from "@/lib/product/api";
import type { RangeKey } from "@/lib/product/vocab";

type SP = Record<string, string | undefined>;

/**
* The range a view opens on when the URL names none: the smallest window that holds
* the latest traffic (a week at least), so a quiet workspace opens on its data
* rather than on an empty week. Asked of the gateway (`/api/metrics/activity`).
*/
export async function activeRange(agent?: string): Promise<{ range: RangeKey; last: string | null }> {
const q = agent ? `?agent=${encodeURIComponent(agent)}` : "";
const a = await safeApi<any>(`/api/metrics/activity${q}`, { range: "7d", last_request_at: null });
return { range: (a.range || "7d") as RangeKey, last: a.last_request_at };
}

/**
* For a page whose numbers depend on the range: with no range in the URL, send the
* reader to the URL with the range that has data, so the filter bar, the links to
* other tabs and everything on the page agree on one window.
*/
export async function ensureRange(path: string, sp: SP, agent?: string): Promise<void> {
if (sp.range) return;
const { range } = await activeRange(agent ?? sp.agent);
if (range !== "7d") redirect(href(path, { ...sp, range }));
}
3 changes: 3 additions & 0 deletions dashboard/lib/product/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export const RANGES = [

export type RangeKey = (typeof RANGES)[number]["key"];

/** A range in words, for labels: "Requests · 30 days". */
export const RANGE_WORDS: Record<RangeKey, string> = { "24h": "24 hours", "7d": "7 days", "30d": "30 days", "90d": "90 days" };

export function rangeOf(value?: string | null): RangeKey {
return (RANGES.find((r) => r.key === value)?.key || "7d") as RangeKey;
}
Expand Down
Binary file modified demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl
Binary file not shown.
3 changes: 2 additions & 1 deletion docs/architecture/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ from the code. Regenerate after changing any route:

<!-- BEGIN GENERATED ROUTES: scripts/gen/api_routes.py --write -->

239 operations, generated from the running app's OpenAPI document. Request and response schemas: `GET /openapi.json` or the interactive `/docs`.
240 operations, generated from the running app's OpenAPI document. Request and response schemas: `GET /openapi.json` or the interactive `/docs`.

### Inline enforcement (`/v1`)

Expand Down Expand Up @@ -132,6 +132,7 @@ from the code. Regenerate after changing any route:
| `GET` | `/api/memory` | List Entries |
| `POST` | `/api/memory/{entry_id}/revoke` | Pull an entry immediately — the concrete fix for 'no way to find and |
| `POST` | `/api/memory/{entry_id}/verify` | A human vouches for an entry — it stops decaying on the unverified TTL. |
| `GET` | `/api/metrics/activity` | Activity |
| `GET` | `/api/metrics/breakdown` | Breakdown |
| `GET` | `/api/metrics/errors` | Steps that failed — a tool that threw, a model call that errored — grouped. |
| `GET` | `/api/metrics/rules` | How each rule behaved: fired, stopped something, or only watched. |
Expand Down
Loading
Loading