diff --git a/plugins/README.md b/plugins/README.md index a33e6f2a..c3896a05 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,12 +1,28 @@ ## Plugins -This directory contains optional plugins/extensions that integrate CyberStrikeAI with other tools. +Optional integrations that connect CyberStrikeAI with other tools. -- `burp-suite/`: Burp Suite extensions - -### Burp Suite Extension +### Burp Suite - **Path**: `plugins/burp-suite/cyberstrikeai-burp-extension/` -- **Build output**: `plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar` -- **Docs**: see the plugin folder `README.md` / `README.zh-CN.md` +- **Build**: `bash build-mvn.sh` → `dist/cyberstrikeai-burp-extension.jar` +- **Docs**: `README.md` / `README.zh-CN.md` +### Browser (Chrome / Edge) + +- **Path**: `plugins/browser-extension/cyberstrikeai-browser-extension/` +- **Version**: **0.3.5** +- **Install**: `chrome://extensions/` → Load unpacked → F12 → **CyberStrikeAI** tab +- **Package**: `bash package.sh` → `dist/cyberstrikeai-browser-extension.zip` +- **Docs**: `README.zh-CN.md` (full) / `README.md` (summary) + +#### Highlights (v0.3.x) + +| Feature | Description | +|---------|-------------| +| Capture pause | **● Capturing** / **○ Paused** — stop recording without closing DevTools | +| HTTP/1.1 display | Raw HAR in memory; UI + AI prompt normalized (no `:method` pseudo-headers) | +| Collapsible conn bar | Host/Port/Validate collapses after success | +| Popup | Read-only endpoint + connection status | +| Performance | XHR-only filter, no body read for static assets, rAF-throttled stream UI | +| Data caps | 200 captures/tab, 50 test runs, 512KB progress — all in-memory | diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/README.md b/plugins/browser-extension/cyberstrikeai-browser-extension/README.md new file mode 100644 index 00000000..33b93a0e --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/README.md @@ -0,0 +1,72 @@ +## CyberStrikeAI Browser Extension + +**Version 0.3.5** — Full docs: **README.zh-CN.md** + +Chromium DevTools extension: capture Network traffic and send it to CyberStrikeAI for AI-assisted security testing. Aligned with the Burp Suite plugin. + +### Quick install + +1. `chrome://extensions/` → Developer mode → **Load unpacked** +2. Select `plugins/browser-extension/cyberstrikeai-browser-extension/` +3. Open target page → **F12** → **CyberStrikeAI** tab → **Validate** +4. Select a captured request → **Send** → view **Output** + +### Popup vs DevTools panel + +| Location | Purpose | +|----------|---------| +| **DevTools panel** | Connection, Validate, capture, Send, Output (primary UI) | +| **Extension popup** | Read-only connection status + version + guide | + +### Key features + +- **Capture toggle**: **● Capturing** / **○ Paused** — pause stops `getContent` and list updates; Send still works on existing entries +- **Collapsible connection bar** — collapses to `https://host:port` after Validate +- **HTTP/1.1 normalization** — raw HAR stored; display and AI prompt strip HTTP/2 pseudo-headers (`:method`, etc.) +- **Test History** (50 runs) + **Captured Requests** (200/tab, XHR/Fetch filter) +- **SSE streaming** — Progress capped at 512KB; Final uncapped for active run +- **Deferred Markdown** — plain text while streaming; render after done; skip above 100KB +- **Stop** — abort local stream + server cancel via `conversationId` +- **Latest XHR**, **Copy**, project/role/agent send dialog +- Session token + optional host permissions + +### Data limits (no unbounded growth) + +| Data | Limit | Storage | +|------|-------|---------| +| Captures | 200 / tab | In-memory | +| Tabs tracked | 20 | In-memory | +| Test runs | 50 | Panel memory | +| Config / token | Small | `chrome.storage` | + +Closing DevTools clears panel data. Closing the browser invalidates the session token. + +### Performance + +- **DevTools closed** → zero impact on page load +- **Capture paused** → near-zero overhead +- **Capturing + XHR only** → light overhead on matching requests only + +### Troubleshooting + +After reloading the extension, close DevTools completely and reopen (F12) if you see `chrome.runtime.connect` errors — the old panel context is invalidated. + +### Package + +```bash +bash package.sh +# → dist/cyberstrikeai-browser-extension.zip +``` + +### Layout + +```text +manifest.json +background/service-worker.js +devtools.js +panel/ # main UI +popup/ # read-only status +lib/ # api, storage, capture, http-normalize, markdown, … +icons/ +package.sh +``` diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/README.zh-CN.md b/plugins/browser-extension/cyberstrikeai-browser-extension/README.zh-CN.md new file mode 100644 index 00000000..be6a652b --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/README.zh-CN.md @@ -0,0 +1,231 @@ +## CyberStrikeAI 浏览器扩展 + +**当前版本:0.3.5** + +Chrome / Edge(Chromium)DevTools 扩展:在开发者工具中捕获 **Network** 流量,发送到 CyberStrikeAI 进行 AI 辅助安全测试。能力与 Burp Suite 插件对齐,并按生产场景做了性能与体验优化。 + +--- + +### 快速开始 + +1. `chrome://extensions/` → 开发者模式 → **加载已解压的扩展程序** +2. 选择目录:`plugins/browser-extension/cyberstrikeai-browser-extension/` +3. 打开目标页面 → **F12** → 顶部 Tab **CyberStrikeAI** +4. 填写 Host / Port / Password → **Validate**(首次会请求访问服务器地址权限) +5. 左侧选中捕获请求 → **Send** → 在 **Output** 查看 AI 结果 + +点击浏览器工具栏图标可查看 **只读连接状态**;完整配置与操作均在 DevTools 面板内完成。 + +--- + +### 界面说明 + +``` +┌─ 连接栏(Validate 成功后可收起)────────────────────────────┐ +│ Logo │ https://host:port │ 连接设置 │ ● OK │ +├─ 操作栏 ────────────────────────────────────────────────────┤ +│ Send │ Latest XHR │ Stop │ Copy │ Clear │ ●捕获中/○已暂停 │ +│ XHR/Fetch only │ Debug │ Markdown │ +├──────────────┬──────────────────────────────────────────────┤ +│ Test History │ Output │ Request │ Response │ +│ Captured Req │ Progress + Final Response │ +└──────────────┴──────────────────────────────────────────────┘ +``` + +| 区域 | 说明 | +|------|------| +| **连接栏** | Host、Port、HTTPS、Password、Validate;成功后收起为 `https://host:port` 摘要 | +| **Test History** | 最多 50 次 Send 记录,可回看 Progress / Final | +| **Captured Requests** | 当前 Tab 捕获列表,最多 200 条,支持搜索 | +| **Output** | 默认 Tab:流式 Progress + Final Response | +| **Request / Response** | 查看选中流量的 HTTP/1.1 格式原文 | + +--- + +### 功能一览 + +#### 捕获 + +- **Background 中枢**:`devtools.js` 监听 Network → `service-worker` 队列 → Panel 订阅 +- 默认 **XHR/Fetch only**(可关闭以捕获更多类型) +- 静态资源 URL / MIME **预过滤**,命中前不读响应体 +- **● 捕获中 / ○ 已暂停**:暂停后零开销,已有列表仍可 Send +- 单条截断:请求体 **64KB**、响应 **4KB** + +#### HTTP 展示与 AI Prompt + +- **存储**:内存中保留原始 HAR(含 HTTP/2 伪首部 `:method` 等) +- **展示 / Prompt**:归一化为 **HTTP/1.1**(与 Burp 插件一致) + +```http +GET /api/foo HTTP/1.1 +Host: example.com +Cookie: ... +``` + +#### 发送到 CyberStrikeAI + +- 弹窗选择:**项目 / 角色 / 对话模式**(动态 API)+ 测试指令 +- 支持 **Eino Single**、**Deep**、**Plan-Execute**、**Supervisor** +- **Latest XHR**:一键选中最近 API 请求并打开发送弹窗 +- **Stop**:中止本地 SSE + 调用服务端 `/api/agent-loop/cancel` + +#### 流式输出 + +- Progress 日志上限 **512KB**(超出截断) +- **Final Response 不截断**(当前进行中的测试) +- 历史 run 切换后 Final 软截断 **100KB** +- **Markdown**:流式阶段纯文本;结束后 `requestIdleCallback` 渲染;超 **100KB** 降级纯文本 +- **Copy**:复制当前 Request / Response / Final + +#### 安全与权限 + +- Token 存 **chrome.storage.session**(关浏览器失效) +- **optional_host_permissions**:Validate 时按需授权 CyberStrikeAI 地址 +- Markdown iframe 使用 `sandbox` + +--- + +### 按钮与选项 + +| 控件 | 作用 | +|------|------| +| **Validate** | 登录并校验 Token;进行中再次点击为 Cancel | +| **连接设置 / 收起** | 展开或折叠 Host/Port/Password 表单 | +| **Send** | 对选中捕获发起到 CyberStrikeAI | +| **Latest XHR** | 选中最近 XHR/Fetch 并 Send | +| **Stop** | 停止当前 AI 流(本地 + 服务端) | +| **Clear Output** | 清空当前 run 的 Progress / Final | +| **● 捕获中 / ○ 已暂停** | 启用或暂停 Network 捕获 | +| **XHR/Fetch only** | 只捕获 API 类请求 | +| **Debug events** | 在 Progress 显示更多 SSE 事件 | +| **Markdown** | Final 完成后渲染富文本 | +| **Clear All** | 清空 Test History | +| **Clear** | 清空当前 Tab 捕获列表 | + +--- + +### 数据与内存(不会无限增长) + +| 数据 | 上限 | 位置 | 清理时机 | +|------|------|------|----------| +| 捕获请求 | 200 条 / Tab | Background + Panel 内存 | 超出丢弃最旧;可手动 Clear | +| Tab 捕获槽 | 20 个 Tab | Background 内存 | 超出丢弃非当前 Tab | +| 测试历史 | 50 条 | Panel 内存 | 超出丢弃最旧;Clear All | +| Progress | 512KB / run | Panel 内存 | 超出截断 | +| Final(进行中) | 无硬上限 | Panel 内存 | — | +| Final(历史) | 100KB 软截断 | Panel 内存 | 切换到其他 run 时 | +| 配置 + Token | 极小 | chrome.storage | 手动改配置 | + +- 关闭 **DevTools** → Panel 内存清空 +- 关闭 **浏览器** → Session Token 失效 +- Service Worker 被回收 → Background 捕获队列清空 + +--- + +### 性能说明 + +| 场景 | 影响 | +|------|------| +| 未开 DevTools | **无影响**(不监听 Network) | +| DevTools 开 + 捕获暂停 | **几乎无影响** | +| DevTools 开 + 捕获中 + XHR only | 仅匹配请求有轻微开销 | +| 高流量 SPA | 建议保持 **XHR/Fetch only**,不需要时点 **已暂停** | + +已做优化:过滤器内存缓存、静态资源不读 body、列表增量插入、搜索防抖、rAF 节流流式 UI。 + +--- + +### 常见问题 + +**扩展更新后报错 `chrome.runtime.connect` undefined?** +扩展重载后旧 DevTools 面板上下文失效。请:**关闭 DevTools → 重新加载扩展 → 再开 F12**。 + +**Request 里为什么曾经有 `:authority`、`:method`?** +HTTP/2 伪首部。展示与 AI Prompt 已归一化为 HTTP/1.1;原始 HAR 仍保存在内存 entry 中。 + +**Console 里 localhost CORS 报错是插件造成的吗?** +不是。那是页面自身请求本机服务被浏览器拦截,与扩展无关。 + +**Test History 很多会挡住 Captured Requests 吗?** +不会。历史区最高占侧边栏 **42%**,超出部分区域内滚动;捕获区占剩余空间。 + +**会拖慢网页吗?** +日常浏览(不开 DevTools)无影响。调试时可用 **已暂停** 完全停止捕获。 + +--- + +### Popup 与 DevTools 分工 + +| 位置 | 用途 | +|------|------| +| **DevTools 面板** | 连接、Validate、捕获、Send、Output(主工作区) | +| **扩展 Popup** | 只读连接状态 + 版本号 + 打开 DevTools 引导 | + +不在 Popup 中重复完整配置表单,避免与主流程脱节。 + +--- + +### 打包发布 + +```bash +bash plugins/browser-extension/cyberstrikeai-browser-extension/package.sh +# → dist/cyberstrikeai-browser-extension.zip +``` + +图标从项目根 `images/logo.png` 生成: + +```bash +LOGO="images/logo.png" +ICONS="plugins/browser-extension/cyberstrikeai-browser-extension/icons" +for size in 16 48 128; do + sips -z $size $size "$LOGO" --out "$ICONS/icon${size}.png" +done +``` + +--- + +### 限制 + +- Chrome **不提供** Network 面板右键菜单 API → 使用 **Latest XHR** + 自建列表 +- Firefox 需 `about:debugging` 临时加载;`storage.session` 不可用时 Token 回退 `local` +- 无法一键从 Popup 跳转到 DevTools 指定面板(Chrome API 限制) + +--- + +### 目录结构 + +```text +manifest.json # MV3 清单 +background/service-worker.js # 捕获队列、Panel Port、全局开关 +devtools.js # Network 监听(最早过滤) +devtools.html +panel/ + panel.html / panel.js / panel.css # 主 UI +popup/ + popup.html / popup.js / popup.css # 只读状态 +lib/ + api.js # 登录、SSE、项目/角色 API + storage.js # 配置与 session token + capture.js # HAR 摘要、静态过滤 + http-normalize.js # HTTP/2 → HTTP/1.1 展示/Prompt + formatter.js # toPrompt 组装 + markdown.js # Final Markdown 渲染 + catalog-cache.js # 项目/角色 5 分钟缓存 + constants.js # 上限常量 +icons/ # 16 / 48 / 128 +package.sh +``` + +--- + +### 与 Burp 插件对比 + +| 能力 | Burp 插件 | 浏览器扩展 | +|------|-----------|------------| +| 流量来源 | Proxy 历史 | DevTools Network | +| 连接配置 | Tab 内 | Tab 内(可折叠) | +| HTTP 格式 | HTTP/1.1 | 展示/Prompt 归一化为 HTTP/1.1 | +| 项目/角色/模式 | Send 弹窗 | Send 弹窗 | +| SSE 输出 | Progress + Final | Progress + Final | +| 捕获开关 | — | ● 捕获中 / ○ 已暂停 | diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/background/service-worker.js b/plugins/browser-extension/cyberstrikeai-browser-extension/background/service-worker.js new file mode 100644 index 00000000..3d39cb2b --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/background/service-worker.js @@ -0,0 +1,143 @@ +/* global chrome, CSAI_LIMITS, shouldCaptureEntry */ + +importScripts('../lib/constants.js', '../lib/capture.js'); + +/** Background hub: per-tab capture queue + panel subscriptions. */ + +const capturesByTab = new Map(); +const portsByTab = new Map(); +const filterApiByTab = new Map(); +let captureEnabled = true; + +function getCaptures(tabId) { + if (!capturesByTab.has(tabId)) capturesByTab.set(tabId, []); + return capturesByTab.get(tabId); +} + +function trimCaptures(list) { + if (list.length > CSAI_LIMITS.MAX_CAPTURED) { + list.length = CSAI_LIMITS.MAX_CAPTURED; + } +} + +function broadcastTab(tabId, message) { + const ports = portsByTab.get(tabId); + if (!ports) return; + for (const port of ports) { + try { + port.postMessage(message); + } catch (_) { + ports.delete(port); + } + } +} + +function cleanupOldTabs(activeTabId) { + if (capturesByTab.size <= CSAI_LIMITS.MAX_TAB_CAPTURES) return; + for (const tabId of capturesByTab.keys()) { + if (tabId !== activeTabId) { + capturesByTab.delete(tabId); + portsByTab.delete(tabId); + filterApiByTab.delete(tabId); + } + if (capturesByTab.size <= CSAI_LIMITS.MAX_TAB_CAPTURES) break; + } +} + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== 'cyberstrike-panel') return; + + port.onDisconnect.addListener(() => { + for (const [tabId, set] of portsByTab.entries()) { + set.delete(port); + if (set.size === 0) portsByTab.delete(tabId); + } + }); + + port.onMessage.addListener((msg) => { + if (!msg || msg.type !== 'subscribe') return; + const tabId = msg.tabId; + if (tabId == null) return; + + if (!portsByTab.has(tabId)) portsByTab.set(tabId, new Set()); + portsByTab.get(tabId).add(port); + + if (typeof msg.filterApiOnly === 'boolean') { + filterApiByTab.set(tabId, msg.filterApiOnly); + } + + port.postMessage({ type: 'list', entries: getCaptures(tabId) }); + cleanupOldTabs(tabId); + }); +}); + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (!msg || !msg.type) return false; + + if (msg.type === 'capture-entry') { + const tabId = msg.tabId; + if (tabId == null) return false; + if (!captureEnabled) { + sendResponse({ ok: true, skipped: true }); + return false; + } + const filterApi = filterApiByTab.has(tabId) ? filterApiByTab.get(tabId) : true; + const entry = msg.entry; + if (!shouldCaptureEntry(entry, filterApi)) { + sendResponse({ ok: true, skipped: true }); + return false; + } + const list = getCaptures(tabId); + list.unshift(entry); + trimCaptures(list); + broadcastTab(tabId, { type: 'entry', entry }); + sendResponse({ ok: true }); + return false; + } + + if (msg.type === 'clear-captures') { + const tabId = msg.tabId; + if (tabId != null) { + capturesByTab.set(tabId, []); + broadcastTab(tabId, { type: 'list', entries: [] }); + } + sendResponse({ ok: true }); + return false; + } + + if (msg.type === 'set-filter-api') { + const tabId = msg.tabId; + if (tabId != null && typeof msg.filterApiOnly === 'boolean') { + filterApiByTab.set(tabId, msg.filterApiOnly); + } + sendResponse({ ok: true }); + return false; + } + + if (msg.type === 'set-capture-enabled') { + if (typeof msg.enabled === 'boolean') { + captureEnabled = msg.enabled; + } + sendResponse({ ok: true }); + return false; + } + + if (msg.type === 'get-latest-api') { + const tabId = msg.tabId; + const list = getCaptures(tabId); + const latest = list.find((e) => { + const rt = (e.resourceType || '').toLowerCase(); + return rt === 'xhr' || rt === 'fetch'; + }); + sendResponse({ entry: latest || null }); + return false; + } + + return false; +}); + +chrome.runtime.onInstalled.addListener(() => { + if (chrome.contextMenus && chrome.contextMenus.removeAll) { + chrome.contextMenus.removeAll(); + } +}); diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.html b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.html new file mode 100644 index 00000000..ad7821fb --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.js b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.js new file mode 100644 index 00000000..9a5f909c --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/devtools.js @@ -0,0 +1,69 @@ +/* global chrome, summarizeHarEntry, inferResourceType, mightCaptureRequest */ + +const FILTER_STORAGE_KEY = 'csai_filter_api_only'; +const CAPTURE_ENABLED_KEY = 'csai_capture_enabled'; + +/** In-memory flags — avoids storage read on every network request. */ +let filterApiOnly = true; +let captureEnabled = true; + +function syncFromStorage(data) { + filterApiOnly = (data && data[FILTER_STORAGE_KEY]) !== false; + captureEnabled = (data && data[CAPTURE_ENABLED_KEY]) !== false; +} + +chrome.storage.local.get([FILTER_STORAGE_KEY, CAPTURE_ENABLED_KEY], (data) => { + syncFromStorage(data); +}); + +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'local') return; + if (changes[FILTER_STORAGE_KEY]) { + filterApiOnly = changes[FILTER_STORAGE_KEY].newValue !== false; + } + if (changes[CAPTURE_ENABLED_KEY]) { + captureEnabled = changes[CAPTURE_ENABLED_KEY].newValue !== false; + } +}); + +chrome.runtime.onMessage.addListener((msg) => { + if (!msg || !msg.type) return; + if (msg.type === 'set-filter-api' && typeof msg.filterApiOnly === 'boolean') { + filterApiOnly = msg.filterApiOnly; + } + if (msg.type === 'set-capture-enabled' && typeof msg.enabled === 'boolean') { + captureEnabled = msg.enabled; + } +}); + +chrome.devtools.network.onRequestFinished.addListener((request) => { + if (!captureEnabled) return; + + const tabId = chrome.devtools.inspectedWindow.tabId; + const req = request.request || {}; + const res = request.response || {}; + const url = req.url || ''; + const resourceType = + (request._resourceType || request.resourceType || inferResourceType(url, res.headers) || 'other') + .toLowerCase(); + + if (!mightCaptureRequest(url, resourceType, filterApiOnly)) { + return; + } + + request.getContent((body) => { + const entry = summarizeHarEntry(request, body, resourceType); + chrome.runtime.sendMessage({ + type: 'capture-entry', + tabId, + entry, + }); + }); +}); + +chrome.devtools.panels.create( + 'CyberStrikeAI', + 'icons/icon48.png', + 'panel/panel.html', + () => {} +); diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/dist/cyberstrikeai-browser-extension.zip b/plugins/browser-extension/cyberstrikeai-browser-extension/dist/cyberstrikeai-browser-extension.zip new file mode 100644 index 00000000..5c708c99 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/dist/cyberstrikeai-browser-extension.zip differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon128.png b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon128.png new file mode 100644 index 00000000..ab13d087 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon128.png differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon16.png b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon16.png new file mode 100644 index 00000000..db614b67 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon16.png differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon48.png b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon48.png new file mode 100644 index 00000000..945b6ac3 Binary files /dev/null and b/plugins/browser-extension/cyberstrikeai-browser-extension/icons/icon48.png differ diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/api.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/api.js new file mode 100644 index 00000000..00766bde --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/api.js @@ -0,0 +1,153 @@ +const AGENT_MODES = [ + { id: 'eino_single', label: 'Eino Single (ADK)', path: '/api/eino-agent/stream' }, + { id: 'deep', label: 'Deep (DeepAgent)', path: '/api/multi-agent/stream', orchestration: 'deep' }, + { id: 'plan_execute', label: 'Plan-Execute', path: '/api/multi-agent/stream', orchestration: 'plan_execute' }, + { id: 'supervisor', label: 'Supervisor', path: '/api/multi-agent/stream', orchestration: 'supervisor' }, +]; + +function agentModeById(id) { + return AGENT_MODES.find((m) => m.id === id) || AGENT_MODES[0]; +} + +async function apiFetch(baseUrl, path, options = {}) { + const res = await fetch(baseUrl + path, options); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch (_) { + json = null; + } + if (!res.ok) { + const err = (json && json.error) || text || `HTTP ${res.status}`; + throw new Error(err); + } + return json; +} + +async function loginAndValidate(baseUrl, password, signal) { + const login = await apiFetch(baseUrl, '/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ password }), + signal, + }); + const token = login && login.token; + if (!token) throw new Error('Login response missing token'); + await apiFetch(baseUrl, '/api/auth/validate', { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + return token; +} + +async function fetchProjects(baseUrl, token, signal) { + const data = await apiFetch(baseUrl, '/api/projects?limit=500', { + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + const list = (data && data.projects) || []; + const out = [{ id: '', label: '(无)' }]; + for (const p of list) { + if (!p.id) continue; + let label = p.name || p.id; + if (p.status === 'archived') label += ' [已归档]'; + out.push({ id: p.id, label }); + } + return out; +} + +async function fetchRoles(baseUrl, token, signal) { + const data = await apiFetch(baseUrl, '/api/roles', { + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + const list = (data && data.roles) || []; + const out = [{ id: '', label: '默认' }]; + for (const r of list) { + if (r.enabled === false) continue; + if (!r.name) continue; + out.push({ id: r.name, label: r.name }); + } + return out; +} + +function extractConversationId(ev) { + if (!ev || typeof ev !== 'object') return ''; + if (ev.conversationId) return String(ev.conversationId).trim(); + if (ev.data && ev.data.conversationId) return String(ev.data.conversationId).trim(); + return ''; +} + +async function streamTest(baseUrl, token, options, handlers) { + const mode = agentModeById(options.agentMode); + const body = { + message: options.message, + conversationId: '', + role: options.role || '', + }; + if (options.projectId) body.projectId = options.projectId; + if (mode.orchestration) body.orchestration = mode.orchestration; + + const controller = new AbortController(); + if (handlers.setAbortController) handlers.setAbortController(controller); + + const res = await fetch(baseUrl + mode.path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!res.ok) { + const t = await res.text(); + throw new Error(t || `HTTP ${res.status}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const json = line.slice(5).trim(); + if (!json) continue; + let ev; + try { + ev = JSON.parse(json); + } catch (_) { + continue; + } + const type = ev.type || ''; + const message = ev.message || ''; + if (handlers.onEvent) handlers.onEvent(type, message, ev); + if (type === 'done') { + if (handlers.onDone) handlers.onDone(); + return; + } + } + } + if (handlers.onDone) handlers.onDone(); +} + +async function cancelByConversationId(baseUrl, token, conversationId) { + await apiFetch(baseUrl, '/api/agent-loop/cancel', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ conversationId }), + }); +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/capture.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/capture.js new file mode 100644 index 00000000..156e0984 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/capture.js @@ -0,0 +1,103 @@ +/** Capture filtering and HAR entry normalization. */ + +const STATIC_EXT = + /\.(js|css|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|map|wasm)(\?|$)/i; + +const STATIC_MIME_PREFIXES = [ + 'image/', + 'font/', + 'audio/', + 'video/', + 'text/css', +]; + +function inferResourceType(url, responseHeaders) { + if (STATIC_EXT.test(url || '')) return 'static'; + const ct = headerValue(responseHeaders, 'content-type').toLowerCase(); + for (const p of STATIC_MIME_PREFIXES) { + if (ct.startsWith(p)) return 'static'; + } + return 'other'; +} + +function headerValue(headers, name) { + if (!headers || !headers.length) return ''; + const lower = name.toLowerCase(); + for (const h of headers) { + if ((h.name || '').toLowerCase() === lower) return h.value || ''; + } + return ''; +} + +function headerLines(headers) { + if (!headers || !headers.length) return ''; + return headers.map((h) => `${h.name}: ${h.value}`).join('\n'); +} + +function truncate(str, max) { + if (!str || str.length <= max) return str || ''; + return str.slice(0, max) + '\n… [truncated]'; +} + +function shouldCaptureEntry(entry, filterApiOnly) { + if (!entry || !entry.url) return false; + return mightCaptureRequest(entry.url, entry.resourceType, filterApiOnly); +} + +/** Fast pre-filter before reading response body in devtools. */ +function mightCaptureRequest(url, resourceType, filterApiOnly) { + const rt = (resourceType || inferResourceType(url, null) || 'other').toLowerCase(); + if (filterApiOnly) { + return rt === 'xhr' || rt === 'fetch' || rt === 'websocket'; + } + if (rt === 'static') return false; + if (STATIC_EXT.test(url || '')) return false; + return true; +} + +function summarizeHarEntry(harEntry, responseBody, resourceType) { + const req = harEntry.request || {}; + const res = harEntry.response || {}; + const url = req.url || ''; + let path = '/'; + try { + const u = new URL(url); + path = u.pathname + (u.search || ''); + } catch (_) { + path = url; + } + const shortPath = path.length > 80 ? path.slice(0, 77) + '...' : path; + const title = `${req.method || 'GET'} ${shortPath}`; + + return { + id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + title, + method: req.method || 'GET', + url, + resourceType: resourceType || 'other', + requestHeaders: headerLines(req.headers), + requestBody: truncate((req.postData && req.postData.text) || '', CSAI_LIMITS.MAX_REQUEST_BODY), + responseStatus: res.status, + responseHeaders: headerLines(res.headers), + responseBody: truncate(responseBody || '', CSAI_LIMITS.MAX_RESPONSE_BODY), + capturedAt: Date.now(), + }; +} + +function summarizePageContext(page) { + return { + id: `page_${Date.now()}`, + title: `PAGE ${page.title || page.url || ''}`.slice(0, 80), + method: 'PAGE', + url: page.url || '', + resourceType: 'document', + requestHeaders: '', + requestBody: '', + responseStatus: 0, + responseHeaders: '', + responseBody: '', + pageTitle: page.title || '', + isPageContext: true, + capturedAt: Date.now(), + }; +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/catalog-cache.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/catalog-cache.js new file mode 100644 index 00000000..649e06be --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/catalog-cache.js @@ -0,0 +1,39 @@ +/** In-memory cache for projects / roles lists (per baseUrl + token). */ + +const CATALOG_CACHE_TTL_MS = 5 * 60 * 1000; + +const catalogCache = { + baseUrl: '', + token: '', + projects: null, + roles: null, + fetchedAt: 0, +}; + +function catalogCacheValid(baseUrl, token) { + if (!catalogCache.fetchedAt) return false; + if (catalogCache.baseUrl !== baseUrl || catalogCache.token !== token) return false; + return Date.now() - catalogCache.fetchedAt < CATALOG_CACHE_TTL_MS; +} + +function invalidateCatalogCache() { + catalogCache.projects = null; + catalogCache.roles = null; + catalogCache.fetchedAt = 0; +} + +async function fetchCatalogCached(baseUrl, token, signal) { + if (catalogCacheValid(baseUrl, token) && catalogCache.projects && catalogCache.roles) { + return { projects: catalogCache.projects, roles: catalogCache.roles }; + } + const [projects, roles] = await Promise.all([ + fetchProjects(baseUrl, token, signal), + fetchRoles(baseUrl, token, signal), + ]); + catalogCache.baseUrl = baseUrl; + catalogCache.token = token; + catalogCache.projects = projects; + catalogCache.roles = roles; + catalogCache.fetchedAt = Date.now(); + return { projects, roles }; +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/constants.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/constants.js new file mode 100644 index 00000000..70b1ffed --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/constants.js @@ -0,0 +1,17 @@ +/** Shared limits and defaults for the browser extension. */ +const CSAI_LIMITS = { + MAX_CAPTURED: 200, + MAX_RUNS: 50, + MAX_REQUEST_BODY: 65536, + MAX_RESPONSE_BODY: 4096, + /** Progress log only; active Final Response is not truncated. */ + MAX_PROGRESS_CHARS: 524288, + MAX_TAB_CAPTURES: 20, + /** Markdown render skipped above this size (plain text only). */ + MAX_MARKDOWN_CHARS: 100000, + /** Non-selected completed runs: soft-trim final to limit memory. */ + MAX_FINAL_ARCHIVE_CHARS: 100000, +}; + +const CSAI_DEFAULT_INSTRUCTION = + '针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口'; diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/formatter.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/formatter.js new file mode 100644 index 00000000..25894b50 --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/formatter.js @@ -0,0 +1,47 @@ +const DEFAULT_INSTRUCTION = CSAI_DEFAULT_INSTRUCTION; + +function defaultInstruction() { + return DEFAULT_INSTRUCTION; +} + +function toPrompt(entry, instruction) { + if (entry && entry.isPageContext) { + const prefix = (instruction && instruction.trim()) ? instruction.trim() : DEFAULT_INSTRUCTION; + return ( + prefix + + '\n\n[Target]\n' + + 'PAGE ' + + (entry.url || '') + + '\n\n[Page]\n' + + 'Title: ' + + (entry.pageTitle || '') + + '\nURL: ' + + (entry.url || '') + ); + } + const prefix = (instruction && instruction.trim()) ? instruction.trim() : DEFAULT_INSTRUCTION; + const method = entry.method || 'GET'; + const url = entry.url || '(unknown)'; + const reqHeaders = normalizeRequestBlock(entry); + const reqBody = entry.requestBody || ''; + let respSnippet = ''; + if (entry.responseHeaders || entry.responseBody) { + respSnippet = + '\n\n[Optional: Response (truncated)]\n' + + normalizeResponseBlock(entry) + + '\n\n' + + (entry.responseBody || ''); + } + return ( + prefix + + '\n\n[Target]\n' + + method + + ' ' + + url + + '\n\n[Request]\n' + + reqHeaders + + '\n\n' + + reqBody + + respSnippet + ); +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/http-normalize.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/http-normalize.js new file mode 100644 index 00000000..ae81176f --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/http-normalize.js @@ -0,0 +1,104 @@ +/** HTTP/2 pseudo-header → HTTP/1.1 normalization for display and AI prompts. + * Raw HAR headers in storage are never modified. */ + +function parseHeaderLines(headerText) { + const headers = []; + for (const line of String(headerText || '').split(/\r?\n/)) { + if (!line.trim()) continue; + const idx = line.indexOf(':'); + if (idx <= 0) continue; + headers.push({ + name: line.slice(0, idx).trim(), + value: line.slice(idx + 1).trim(), + }); + } + return headers; +} + +function urlParts(url) { + try { + const u = new URL(url || ''); + return { + host: u.host, + path: (u.pathname || '/') + (u.search || ''), + }; + } catch (_) { + return { host: '', path: '/' }; + } +} + +/** Request line + headers (HTTP/1.1), no body. */ +function normalizeRequestBlock(entry) { + if (!entry || entry.isPageContext) return entry?.requestHeaders || ''; + + const headers = parseHeaderLines(entry.requestHeaders); + const fromUrl = urlParts(entry.url); + let method = entry.method || 'GET'; + let path = fromUrl.path || '/'; + let host = fromUrl.host; + + const regular = []; + let hasHost = false; + + for (const h of headers) { + const name = h.name; + const lower = name.toLowerCase(); + if (name.startsWith(':')) { + if (lower === ':method') method = h.value || method; + else if (lower === ':path') path = h.value || path; + else if (lower === ':authority') host = h.value || host; + continue; + } + if (lower === 'host') hasHost = true; + regular.push(`${name}: ${h.value}`); + } + + if (host && !hasHost) { + regular.unshift(`Host: ${host}`); + } + + if (!path.startsWith('/')) path = '/' + path; + return `${method} ${path} HTTP/1.1\n${regular.join('\n')}`; +} + +/** Status line + headers (HTTP/1.1), no body. */ +function normalizeResponseBlock(entry) { + if (!entry) return ''; + + const headers = parseHeaderLines(entry.responseHeaders); + let status = entry.responseStatus || 0; + const regular = []; + + for (const h of headers) { + const name = h.name; + const lower = name.toLowerCase(); + if (name.startsWith(':')) { + if (lower === ':status') { + const n = parseInt(h.value, 10); + if (!Number.isNaN(n)) status = n; + } + continue; + } + regular.push(`${name}: ${h.value}`); + } + + const statusLine = `HTTP/1.1 ${status || '?'}`; + return regular.length ? `${statusLine}\n${regular.join('\n')}` : statusLine; +} + +function formatRequestDisplay(entry) { + if (!entry) return ''; + if (entry.isPageContext) { + return `PAGE ${entry.url || ''}\n\nTitle: ${entry.pageTitle || ''}`; + } + const block = normalizeRequestBlock(entry); + const body = entry.requestBody || ''; + return body ? `${block}\n\n${body}` : block; +} + +function formatResponseDisplay(entry) { + if (!entry) return ''; + const block = normalizeResponseBlock(entry); + const body = entry.responseBody || ''; + return body ? `${block}\n\n${body}` : block; +} diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/markdown.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/markdown.js new file mode 100644 index 00000000..a07ec9af --- /dev/null +++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/markdown.js @@ -0,0 +1,135 @@ +/** Minimal Markdown → HTML (aligned with Burp plugin renderer). */ + +function mdEscapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function mdHeadingLevel(s) { + let i = 0; + while (i < s.length && s[i] === '#') i++; + if (i >= 1 && i <= 6 && i < s.length && /\s/.test(s[i])) return i; + return 0; +} + +function mdReplaceInlineCode(s) { + let out = ''; + let inCode = false; + let buf = ''; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '`') { + if (!inCode) { + inCode = true; + buf = ''; + } else { + out += `${buf}`;
+ inCode = false;
+ }
+ continue;
+ }
+ if (inCode) buf += c;
+ else out += c;
+ }
+ if (inCode) out += '`' + buf;
+ return out;
+}
+
+function mdReplaceBold(s) {
+ let out = '';
+ let i = 0;
+ while (i < s.length) {
+ const start = s.indexOf('**', i);
+ if (start < 0) {
+ out += s.slice(i);
+ break;
+ }
+ const end = s.indexOf('**', start + 2);
+ if (end < 0) {
+ out += s.slice(i);
+ break;
+ }
+ out += s.slice(i, start) + '' + s.slice(start + 2, end) + '';
+ i = end + 2;
+ }
+ return out;
+}
+
+function mdInlineFormat(text) {
+ let escaped = mdEscapeHtml(text);
+ escaped = mdReplaceInlineCode(escaped);
+ escaped = mdReplaceBold(escaped);
+ return escaped;
+}
+
+function markdownToHtml(markdown) {
+ const lines = String(markdown || '').split(/\r?\n/);
+ const css =
+ 'body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1.45;margin:10px;color:#111;}' +
+ 'code,pre{font-family:ui-monospace,Menlo,Consolas,monospace;}' +
+ 'code{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:4px;padding:0 4px;}' +
+ 'pre{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:6px;padding:10px;overflow:auto;}' +
+ 'pre code{background:transparent;border:none;padding:0;}' +
+ 'p{margin:0.55em 0;}ul{margin:0.4em 0 0.6em 1.2em;padding:0;}';
+
+ let out = ``;
+ let inCode = false;
+ let inList = false;
+ let codeBuf = '';
+
+ for (const raw of lines) {
+ const line = raw == null ? '' : raw;
+ if (line.trim().startsWith('```')) {
+ if (!inCode) {
+ inCode = true;
+ codeBuf = '';
+ } else {
+ out += `${mdEscapeHtml(codeBuf)}`;
+ inCode = false;
+ }
+ continue;
+ }
+ if (inCode) {
+ codeBuf += line + '\n';
+ continue;
+ }
+ const trimmed = line.trim();
+ if (!trimmed) {
+ if (inList) {
+ out += '';
+ inList = false;
+ }
+ out += "";
+ continue;
+ }
+ const h = mdHeadingLevel(trimmed);
+ if (h > 0) {
+ if (inList) {
+ out += '';
+ inList = false;
+ }
+ out += `${mdInlineFormat(trimmed)}
`; + } + if (inCode) out += `${mdEscapeHtml(codeBuf)}`;
+ if (inList) out += '';
+ out += '';
+ return out;
+}
diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/lib/storage.js b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/storage.js
new file mode 100644
index 00000000..2ba1cdcc
--- /dev/null
+++ b/plugins/browser-extension/cyberstrikeai-browser-extension/lib/storage.js
@@ -0,0 +1,114 @@
+/* global chrome */
+
+const STORAGE_KEYS = {
+ host: 'csai_host',
+ port: 'csai_port',
+ https: 'csai_https',
+ lastProjectId: 'csai_last_project',
+ lastRole: 'csai_last_role',
+ lastAgentMode: 'csai_last_agent_mode',
+ lastInstruction: 'csai_last_instruction',
+ filterApiOnly: 'csai_filter_api_only',
+ captureEnabled: 'csai_capture_enabled',
+ renderMarkdown: 'csai_render_markdown',
+ showDebugEvents: 'csai_show_debug',
+};
+
+const SESSION_TOKEN_KEY = 'csai_token';
+
+const DEFAULTS = {
+ host: '127.0.0.1',
+ port: '8080',
+ https: true,
+ lastInstruction: CSAI_DEFAULT_INSTRUCTION,
+ filterApiOnly: true,
+ captureEnabled: true,
+ renderMarkdown: true,
+ showDebugEvents: false,
+};
+
+function localGet(keys) {
+ return new Promise((resolve) => chrome.storage.local.get(keys, resolve));
+}
+
+function localSet(obj) {
+ return new Promise((resolve) => chrome.storage.local.set(obj, resolve));
+}
+
+function sessionGet(keys) {
+ return new Promise((resolve) => {
+ if (!chrome.storage.session) {
+ chrome.storage.local.get(keys, resolve);
+ return;
+ }
+ chrome.storage.session.get(keys, resolve);
+ });
+}
+
+function sessionSet(obj) {
+ return new Promise((resolve) => {
+ if (!chrome.storage.session) {
+ chrome.storage.local.set(obj, resolve);
+ return;
+ }
+ chrome.storage.session.set(obj, resolve);
+ });
+}
+
+async function loadConfig() {
+ const data = await localGet(Object.values(STORAGE_KEYS));
+ const sess = await sessionGet([SESSION_TOKEN_KEY]);
+ return {
+ host: data[STORAGE_KEYS.host] || DEFAULTS.host,
+ port: data[STORAGE_KEYS.port] || DEFAULTS.port,
+ https: data[STORAGE_KEYS.https] !== false,
+ token: sess[SESSION_TOKEN_KEY] || '',
+ lastProjectId: data[STORAGE_KEYS.lastProjectId] || '',
+ lastRole: data[STORAGE_KEYS.lastRole] || '',
+ lastAgentMode: data[STORAGE_KEYS.lastAgentMode] || 'eino_single',
+ lastInstruction: data[STORAGE_KEYS.lastInstruction] || DEFAULTS.lastInstruction,
+ filterApiOnly: data[STORAGE_KEYS.filterApiOnly] !== false,
+ captureEnabled: data[STORAGE_KEYS.captureEnabled] !== false,
+ renderMarkdown: data[STORAGE_KEYS.renderMarkdown] !== false,
+ showDebugEvents: data[STORAGE_KEYS.showDebugEvents] === true,
+ };
+}
+
+async function saveConfig(partial) {
+ const localMap = {};
+ if (partial.host != null) localMap[STORAGE_KEYS.host] = partial.host;
+ if (partial.port != null) localMap[STORAGE_KEYS.port] = partial.port;
+ if (partial.https != null) localMap[STORAGE_KEYS.https] = partial.https;
+ if (partial.lastProjectId != null) localMap[STORAGE_KEYS.lastProjectId] = partial.lastProjectId;
+ if (partial.lastRole != null) localMap[STORAGE_KEYS.lastRole] = partial.lastRole;
+ if (partial.lastAgentMode != null) localMap[STORAGE_KEYS.lastAgentMode] = partial.lastAgentMode;
+ if (partial.lastInstruction != null) localMap[STORAGE_KEYS.lastInstruction] = partial.lastInstruction;
+ if (partial.filterApiOnly != null) localMap[STORAGE_KEYS.filterApiOnly] = partial.filterApiOnly;
+ if (partial.captureEnabled != null) localMap[STORAGE_KEYS.captureEnabled] = partial.captureEnabled;
+ if (partial.renderMarkdown != null) localMap[STORAGE_KEYS.renderMarkdown] = partial.renderMarkdown;
+ if (partial.showDebugEvents != null) localMap[STORAGE_KEYS.showDebugEvents] = partial.showDebugEvents;
+ if (Object.keys(localMap).length) await localSet(localMap);
+ if (partial.token != null) await sessionSet({ [SESSION_TOKEN_KEY]: partial.token });
+}
+
+function baseUrlFrom(cfg) {
+ const scheme = cfg.https ? 'https' : 'http';
+ return `${scheme}://${cfg.host}:${cfg.port}`;
+}
+
+/** Request optional host permission for the configured CyberStrikeAI origin. */
+async function ensureHostPermission(baseUrl) {
+ if (!chrome.permissions || !chrome.permissions.request) return;
+ let origin;
+ try {
+ origin = new URL(baseUrl).origin + '/*';
+ } catch (_) {
+ throw new Error('Invalid Host/Port');
+ }
+ const has = await chrome.permissions.contains({ origins: [origin] });
+ if (has) return;
+ const granted = await chrome.permissions.request({ origins: [origin] });
+ if (!granted) {
+ throw new Error('需要授权访问 CyberStrikeAI 服务器地址');
+ }
+}
diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/manifest.json b/plugins/browser-extension/cyberstrikeai-browser-extension/manifest.json
new file mode 100644
index 00000000..fbe2bfa3
--- /dev/null
+++ b/plugins/browser-extension/cyberstrikeai-browser-extension/manifest.json
@@ -0,0 +1,21 @@
+{
+ "manifest_version": 3,
+ "name": "CyberStrikeAI",
+ "version": "0.3.5",
+ "description": "Capture browser HTTP traffic and send to CyberStrikeAI for AI-assisted security testing.",
+ "devtools_page": "devtools.html",
+ "permissions": ["storage"],
+ "optional_host_permissions": ["http://*/*", "https://*/*"],
+ "background": {
+ "service_worker": "background/service-worker.js"
+ },
+ "action": {
+ "default_popup": "popup/popup.html",
+ "default_title": "CyberStrikeAI"
+ },
+ "icons": {
+ "16": "icons/icon16.png",
+ "48": "icons/icon48.png",
+ "128": "icons/icon128.png"
+ }
+}
diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/package.sh b/plugins/browser-extension/cyberstrikeai-browser-extension/package.sh
new file mode 100644
index 00000000..6282a4b3
--- /dev/null
+++ b/plugins/browser-extension/cyberstrikeai-browser-extension/package.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+OUT="$ROOT/dist/cyberstrikeai-browser-extension.zip"
+mkdir -p "$ROOT/dist"
+rm -f "$OUT"
+(cd "$ROOT" && zip -r "$OUT" . \
+ -x './dist/*' -x './package.sh' -x '*/.DS_Store')
+echo "[+] $OUT"
diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.css b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.css
new file mode 100644
index 00000000..5b2d332a
--- /dev/null
+++ b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.css
@@ -0,0 +1,867 @@
+/* CyberStrikeAI DevTools panel — light default, dark via prefers-color-scheme */
+
+:root {
+ --csai-accent: #ea580c;
+ --csai-accent-hover: #c2410c;
+ --csai-accent-soft: rgba(234, 88, 12, 0.12);
+ --csai-accent-ring: rgba(234, 88, 12, 0.28);
+
+ --csai-bg: #f4f5f7;
+ --csai-surface: #ffffff;
+ --csai-surface-2: #f8f9fb;
+ --csai-surface-3: #eef0f4;
+ --csai-border: #e2e5eb;
+ --csai-border-strong: #cdd2dc;
+
+ --csai-text: #1a1d24;
+ --csai-text-muted: #5c6470;
+ --csai-text-faint: #8b939e;
+
+ --csai-success: #16a34a;
+ --csai-success-bg: rgba(22, 163, 74, 0.1);
+ --csai-danger: #dc2626;
+ --csai-danger-bg: rgba(220, 38, 38, 0.1);
+ --csai-info: #2563eb;
+ --csai-warn: #d97706;
+
+ --csai-radius: 8px;
+ --csai-radius-sm: 6px;
+ --csai-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
+ --csai-shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08);
+
+ --csai-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --csai-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+
+ --csai-control-h: 30px;
+ --csai-bar-h: 40px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --csai-bg: #1a1d23;
+ --csai-surface: #22262e;
+ --csai-surface-2: #2a2f38;
+ --csai-surface-3: #323844;
+ --csai-border: #3a404c;
+ --csai-border-strong: #4a5160;
+
+ --csai-text: #e8eaed;
+ --csai-text-muted: #9aa3af;
+ --csai-text-faint: #6b7280;
+
+ --csai-accent-soft: rgba(251, 146, 60, 0.15);
+ --csai-accent-ring: rgba(251, 146, 60, 0.35);
+ --csai-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+ --csai-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35);
+ }
+}
+
+* { box-sizing: border-box; }
+
+.ctx-banner {
+ padding: 8px 12px;
+ background: #fef3c7;
+ border-bottom: 1px solid #f59e0b;
+ color: #92400e;
+ font-size: 12px;
+ font-weight: 500;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.ctx-banner.hidden { display: none; }
+
+@media (prefers-color-scheme: dark) {
+ .ctx-banner {
+ background: rgba(245, 158, 11, 0.15);
+ color: #fbbf24;
+ border-color: rgba(245, 158, 11, 0.35);
+ }
+}
+
+body {
+ margin: 0;
+ font: 12px/1.45 var(--csai-font);
+ color: var(--csai-text);
+ background: var(--csai-bg);
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── Toolbar ── */
+.toolbar {
+ background: var(--csai-surface);
+ border-bottom: 1px solid var(--csai-border);
+ padding: 8px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ box-shadow: var(--csai-shadow);
+}
+
+.toolbar-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ min-height: var(--csai-control-h);
+}
+
+.toolbar-row--conn {
+ gap: 10px;
+ flex: 1;
+ min-width: 0;
+}
+
+.toolbar--conn-collapsed .conn-details {
+ display: none;
+}
+
+.toolbar:not(.toolbar--conn-collapsed) .conn-summary {
+ display: none;
+}
+
+.conn-summary {
+ flex: 1;
+ min-width: 0;
+ padding: 0 10px;
+ height: var(--csai-control-h);
+ display: inline-flex;
+ align-items: center;
+ font-size: 11px;
+ font-weight: 600;
+ font-family: var(--csai-mono);
+ color: var(--csai-text-muted);
+ background: var(--csai-surface-2);
+ border: 1px solid var(--csai-border);
+ border-radius: var(--csai-radius-sm);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.conn-summary--ok {
+ color: var(--csai-success);
+ background: var(--csai-success-bg);
+ border-color: rgba(22, 163, 74, 0.25);
+}
+
+.conn-details {
+ padding-bottom: 2px;
+}
+
+.conn-details .conn-group {
+ flex: 1;
+}
+
+#btn-conn-toggle {
+ flex-shrink: 0;
+}
+
+.toolbar-row--top { gap: 12px; }
+
+.toolbar-row--bottom {
+ justify-content: space-between;
+ align-items: center;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ height: var(--csai-control-h);
+ padding-right: 12px;
+ border-right: 1px solid var(--csai-border);
+}
+
+.brand-logo {
+ width: 24px;
+ height: 24px;
+ border-radius: var(--csai-radius-sm);
+ box-shadow: var(--csai-shadow);
+}
+
+.brand-name {
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--csai-text);
+ line-height: 1;
+}
+
+.conn-group,
+.action-group,
+.option-group {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.conn-group { flex: 1; min-width: 0; }
+
+.conn-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.action-divider {
+ width: 1px;
+ height: 18px;
+ background: var(--csai-border);
+ flex-shrink: 0;
+}
+
+/* Toolbar: inline label + control on one baseline */
+.toolbar .field {
+ flex-direction: row;
+ align-items: center;
+ gap: 6px;
+}
+
+.toolbar .field-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: none;
+ letter-spacing: 0;
+ color: var(--csai-text-muted);
+ line-height: 1;
+ white-space: nowrap;
+}
+
+/* ── Form fields ── */
+.field {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.field.block {
+ display: flex;
+ width: 100%;
+}
+
+.field-label {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--csai-text-faint);
+}
+
+.field input[type="text"],
+.field input[type="password"],
+.field select,
+.field textarea,
+#search {
+ height: var(--csai-control-h);
+ padding: 0 10px;
+ border: 1px solid var(--csai-border);
+ border-radius: var(--csai-radius-sm);
+ background: var(--csai-surface-2);
+ color: var(--csai-text);
+ font: inherit;
+ line-height: normal;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.field textarea {
+ height: auto;
+ min-height: 120px;
+ padding: 8px 10px;
+}
+
+.field input:focus,
+.field select:focus,
+.field textarea:focus,
+#search:focus {
+ outline: none;
+ border-color: var(--csai-accent);
+ box-shadow: 0 0 0 3px var(--csai-accent-ring);
+}
+
+.field--sm input { width: 64px; }
+
+#host { width: 120px; }
+#password { width: 100px; }
+
+.search-wrap {
+ padding: 8px 10px 4px;
+}
+
+#search {
+ width: 100%;
+ background: var(--csai-surface-2);
+}
+
+#search::placeholder { color: var(--csai-text-faint); }
+
+/* ── Toggle pills ── */
+.toggle-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ height: var(--csai-control-h);
+ padding: 0 12px;
+ border-radius: var(--csai-radius-sm);
+ border: 1px solid var(--csai-border);
+ background: var(--csai-surface-2);
+ cursor: pointer;
+ font-size: 11px;
+ line-height: 1;
+ color: var(--csai-text-muted);
+ user-select: none;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+ flex-shrink: 0;
+}
+
+.toggle-pill:hover {
+ border-color: var(--csai-border-strong);
+ color: var(--csai-text);
+}
+
+.toggle-pill:has(input:checked) {
+ background: var(--csai-accent-soft);
+ border-color: var(--csai-accent);
+ color: var(--csai-accent);
+ font-weight: 600;
+}
+
+.toggle-pill input { margin: 0; accent-color: var(--csai-accent); }
+
+/* ── Buttons ── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: var(--csai-control-h);
+ padding: 0 14px;
+ border-radius: var(--csai-radius-sm);
+ border: 1px solid transparent;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, transform 0.1s, opacity 0.15s;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.btn:active:not(:disabled) { transform: scale(0.98); }
+.btn:disabled { opacity: 0.45; cursor: not-allowed; }
+
+.btn--primary {
+ background: var(--csai-accent);
+ color: #fff;
+ border-color: var(--csai-accent);
+}
+
+.btn--primary:hover:not(:disabled) {
+ background: var(--csai-accent-hover);
+ border-color: var(--csai-accent-hover);
+}
+
+.btn--secondary {
+ background: var(--csai-surface-2);
+ color: var(--csai-text);
+ border-color: var(--csai-border);
+}
+
+.btn--secondary:hover:not(:disabled) {
+ background: var(--csai-surface-3);
+ border-color: var(--csai-border-strong);
+}
+
+.btn--danger {
+ background: var(--csai-danger-bg);
+ color: var(--csai-danger);
+ border-color: rgba(220, 38, 38, 0.25);
+}
+
+.btn--danger:hover:not(:disabled) {
+ background: rgba(220, 38, 38, 0.18);
+}
+
+.btn--ghost {
+ background: transparent;
+ color: var(--csai-text-muted);
+ border-color: var(--csai-border);
+}
+
+.btn--ghost:hover:not(:disabled) {
+ background: var(--csai-surface-2);
+ color: var(--csai-text);
+}
+
+.btn--capture-on {
+ background: var(--csai-success-bg);
+ color: var(--csai-success);
+ border-color: rgba(22, 163, 74, 0.3);
+}
+
+.btn--capture-on:hover:not(:disabled) {
+ background: rgba(22, 163, 74, 0.18);
+}
+
+.btn--capture-off {
+ background: var(--csai-surface-2);
+ color: var(--csai-text-muted);
+ border-color: var(--csai-border);
+}
+
+.btn--capture-off:hover:not(:disabled) {
+ background: var(--csai-surface-3);
+ color: var(--csai-text);
+}
+
+.link-btn {
+ border: none;
+ background: none;
+ color: var(--csai-accent);
+ padding: 2px 6px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ border-radius: var(--csai-radius-sm);
+}
+
+.link-btn:hover {
+ background: var(--csai-accent-soft);
+}
+
+/* ── Status badge ── */
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: var(--csai-control-h);
+ padding: 0 12px;
+ border-radius: var(--csai-radius-sm);
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--csai-text-muted);
+ background: var(--csai-surface-2);
+ border: 1px solid var(--csai-border);
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.status::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--csai-text-faint);
+ flex-shrink: 0;
+}
+
+.status-ok {
+ color: var(--csai-success);
+ background: var(--csai-success-bg);
+ border-color: rgba(22, 163, 74, 0.25);
+}
+
+.status-ok::before { background: var(--csai-success); }
+
+.status-error {
+ color: var(--csai-danger);
+ background: var(--csai-danger-bg);
+ border-color: rgba(220, 38, 38, 0.25);
+}
+
+.status-error::before { background: var(--csai-danger); }
+
+.status-pending {
+ color: var(--csai-info);
+ background: rgba(37, 99, 235, 0.1);
+ border-color: rgba(37, 99, 235, 0.25);
+}
+
+.status-pending::before {
+ background: var(--csai-info);
+ animation: pulse 1.2s ease-in-out infinite;
+}
+
+.status-warn {
+ color: var(--csai-warn);
+ background: rgba(217, 119, 6, 0.1);
+ border-color: rgba(217, 119, 6, 0.25);
+}
+
+.status-warn::before { background: var(--csai-warn); }
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+
+/* ── Layout ── */
+.layout {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+ padding: 10px;
+ gap: 10px;
+ align-items: stretch;
+}
+
+.sidebar {
+ width: 300px;
+ flex-shrink: 0;
+ background: var(--csai-surface);
+ border: 1px solid var(--csai-border);
+ border-radius: var(--csai-radius);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ box-shadow: var(--csai-shadow);
+}
+
+.sidebar-section {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-bottom: 1px solid var(--csai-border);
+ max-height: 42%;
+}
+
+.sidebar-section.grow {
+ flex: 1;
+ max-height: none;
+ border-bottom: none;
+}
+
+.sidebar-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ height: var(--csai-bar-h);
+ min-height: var(--csai-bar-h);
+ padding: 0 12px;
+ background: var(--csai-surface-2);
+ border-bottom: 1px solid var(--csai-border);
+ flex-shrink: 0;
+}
+
+.sidebar-head strong {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--csai-text-muted);
+}
+
+.capture-paused-hint {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--csai-warn);
+}
+
+.sidebar-head .link-btn {
+ margin-left: auto;
+}
+
+.sidebar.capture-paused .request-list {
+ opacity: 0.55;
+}
+
+/* ── Lists ── */
+.run-list,
+.request-list {
+ list-style: none;
+ margin: 0;
+ padding: 4px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ flex: 1;
+ min-width: 0;
+}
+
+.run-list li,
+.request-list li {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px 10px;
+ margin-bottom: 2px;
+ border-radius: var(--csai-radius-sm);
+ cursor: pointer;
+ font-size: 11px;
+ line-height: 1.4;
+ border: 1px solid transparent;
+ transition: background 0.12s, border-color 0.12s;
+}
+
+.run-list li:hover,
+.request-list li:hover {
+ background: var(--csai-surface-2);
+}
+
+.run-list li.selected,
+.request-list li.selected {
+ background: var(--csai-accent-soft);
+ border-color: rgba(234, 88, 12, 0.35);
+}
+
+.run-body { min-width: 0; flex: 1; }
+
+.run-title {
+ font-weight: 600;
+ color: var(--csai-text);
+ word-break: break-all;
+}
+
+.run-list .meta {
+ color: var(--csai-text-faint);
+ font-size: 10px;
+ margin-top: 3px;
+}
+
+.dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-top: 4px;
+ flex-shrink: 0;
+ box-shadow: 0 0 0 2px var(--csai-surface);
+}
+
+.dot-running {
+ background: var(--csai-info);
+ box-shadow: 0 0 0 2px var(--csai-surface), 0 0 6px rgba(37, 99, 235, 0.5);
+}
+
+.dot-done { background: var(--csai-success); }
+.dot-error { background: var(--csai-danger); }
+.dot-cancelled { background: var(--csai-warn); }
+
+.request-list .method {
+ font-weight: 700;
+ font-size: 10px;
+ padding: 1px 5px;
+ border-radius: 4px;
+ background: var(--csai-accent-soft);
+ color: var(--csai-accent);
+ margin-right: 6px;
+ flex-shrink: 0;
+}
+
+.request-list .status-ok {
+ font-weight: 700;
+ font-size: 10px;
+ color: var(--csai-success);
+ margin-right: 4px;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.request-list .status-err {
+ font-weight: 700;
+ font-size: 10px;
+ color: var(--csai-danger);
+ margin-right: 4px;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.request-list li {
+ align-items: flex-start;
+ min-width: 0;
+}
+
+.request-list .req-path {
+ flex: 1;
+ min-width: 0;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+ line-height: 1.4;
+}
+
+/* ── Main panel ── */
+.main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: var(--csai-surface);
+ border: 1px solid var(--csai-border);
+ border-radius: var(--csai-radius);
+ overflow: hidden;
+ box-shadow: var(--csai-shadow);
+}
+
+.tabs {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ height: var(--csai-bar-h);
+ min-height: var(--csai-bar-h);
+ padding: 0 10px;
+ background: var(--csai-surface-2);
+ border-bottom: 1px solid var(--csai-border);
+ flex-shrink: 0;
+ box-sizing: border-box;
+}
+
+.tab {
+ border: none;
+ border-radius: var(--csai-radius-sm) var(--csai-radius-sm) 0 0;
+ background: transparent;
+ height: calc(var(--csai-bar-h) - 1px);
+ padding: 0 16px;
+ display: inline-flex;
+ align-items: center;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1;
+ color: var(--csai-text-muted);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: color 0.15s, background 0.15s;
+}
+
+.tab:hover {
+ color: var(--csai-text);
+ background: var(--csai-surface);
+}
+
+.tab.active {
+ color: var(--csai-accent);
+ background: var(--csai-surface);
+ border-bottom-color: var(--csai-accent);
+}
+
+.tab-panel {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--csai-surface);
+}
+
+.tab-panel.hidden { display: none; }
+
+.split {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.pane {
+ border-bottom: 1px solid var(--csai-border);
+ display: flex;
+ flex-direction: column;
+ min-height: 72px;
+ max-height: 38%;
+}
+
+.pane.grow {
+ flex: 1;
+ max-height: none;
+ border-bottom: none;
+ position: relative;
+}
+
+.pane-title {
+ padding: 6px 12px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--csai-text-faint);
+ background: var(--csai-surface-2);
+ border-bottom: 1px solid var(--csai-border);
+}
+
+.log {
+ margin: 0;
+ padding: 12px;
+ overflow: auto;
+ flex: 1;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font: 11px/1.55 var(--csai-mono);
+ color: var(--csai-text);
+ background: var(--csai-surface);
+}
+
+.log.full { height: 100%; }
+
+.md-frame {
+ border: none;
+ width: 100%;
+ flex: 1;
+ background: var(--csai-surface);
+}
+
+.hidden { display: none !important; }
+
+/* ── Dialog ── */
+#send-dialog {
+ border: 1px solid var(--csai-border);
+ border-radius: 12px;
+ padding: 0;
+ width: min(720px, 92vw);
+ background: var(--csai-surface);
+ color: var(--csai-text);
+ box-shadow: var(--csai-shadow-md);
+}
+
+#send-dialog::backdrop {
+ background: rgba(0, 0, 0, 0.45);
+ backdrop-filter: blur(2px);
+}
+
+#send-dialog form { padding: 20px; margin: 0; }
+
+.dialog-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.dialog-logo {
+ border-radius: var(--csai-radius-sm);
+ box-shadow: var(--csai-shadow);
+}
+
+#send-dialog h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.send-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+#dlg-instruction {
+ resize: vertical;
+ font-family: var(--csai-font);
+ min-height: 120px;
+}
+
+.dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px solid var(--csai-border);
+}
diff --git a/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.html b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.html
new file mode 100644
index 00000000..936d92c2
--- /dev/null
+++ b/plugins/browser-extension/cyberstrikeai-browser-extension/panel/panel.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+ 主工作区在 DevTools 面板:按 F12 → CyberStrikeAI
+ +