@dsh-fly/dsh-plugin-fly-engagement 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSH Fly contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @dsh-fly/dsh-plugin-fly-engagement
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH Fly 的 **engagement 记录面**(Security / CTF 作战室的"把发现沉淀下来"那一半):
4
+
5
+ - **agent 平面**(`tools/index.js`,由 Fly preset 按会话挂载):`eng_record`(goal/facts/findings/assets/edges,
6
+ 漏洞**必须带复现步骤**)、`eng_state`(投影 + `counts`/`dropped`/`refused`/`truncated`)、
7
+ `eng_report`(全量 Markdown 报告)。
8
+ - **宿主平面**(根 `index.js`,`inject: ['webServer','shell']`):`GET /fly/engagement/state|report`,
9
+ 给抽屉面板取数;两半共用 `lib/fold.mjs` 的投影口径 ⇒ 面板读数与 `eng_state` 永远同口径。
10
+
11
+ 存储:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影 = fold)。
12
+ **不写任何自定义会话事件**(DSH 毒药条);文件读写全走 `ctx.shell` 接缝。
13
+
14
+ 借鉴 `@howmp/dsh-pentest`(MIT)的数据形状(六表 + 带词汇的边 + 必填步骤 + 双计数 + 确定性 id),
15
+ 承载面用 DSH Fly 自己的抽屉面板。设计与验收见 dsh-fly 仓 `docs/security-mode-review-and-plan.md` §11。
16
+
17
+ ## 形状(与 fly-smart 同一条:一个包、两种挂载)
18
+
19
+ | 挂载点 | 文件 | 干什么 |
20
+ | --- | --- | --- |
21
+ | 宿主行(bundle patch,**裸包名**) | `index.js` | 端点 + 客户端半边(客户端半边要求行名是裸包名,带 `/` 不会被登记) |
22
+ | agent 行(preset 指向子路径) | `tools/index.js` | 三个 `eng_*` 工具 |
23
+
24
+ ## 门禁
25
+
26
+ ```bash
27
+ node scripts/verify.mjs # 包内测试 + 静态钉子(毒药条 / shell 接缝 / 裸包名 / dsh.client)
28
+ ```
@@ -0,0 +1,11 @@
1
+ # dsh-plugin-fly-engagement 的**宿主层**声明:只有工作台那一页(路径规划 §11.3b)。
2
+ #
3
+ # **行名必须是裸包名**:`dsh-client-modules` 的 `exactPackageSpecifier()` 对带 `/` 的行名
4
+ # 一律返回 undefined ⇒ 客户端半边不会被登记、面板在浏览器里根本不出现(fly-smart 的 patch
5
+ # 注释记着同一条实测)。所以本包的裸名 = 宿主平面的面板行,agent 平面的工具挪到包内子路径
6
+ # `tools/index.js`,由 preset 按会话挂载 —— 两条纪律同时满足:
7
+ # ① 本 patch 里**没有** `inject: ['tools']` 的行(宿主不会在开机激活工具插件);
8
+ # ② 面板这一行是裸名 ⇒ 它的客户端半边登记得上。
9
+ - insert:
10
+ - id: fly-engagement-panel
11
+ name: @dsh-fly/dsh-plugin-fly-engagement
package/index.js ADDED
@@ -0,0 +1,101 @@
1
+ import { foldEvents, ledgerPath, renderReport, windowView } from './lib/fold.mjs'
2
+
3
+ /**
4
+ * fly-engagement —— **宿主平面的面板行**(根文件 = 裸包名,客户端半边才登记得上)。
5
+ *
6
+ * 形状照 `dsh-plugin-fly-smart`(一个包、两种挂载):
7
+ * · 根 `index.js`(本文件)= 宿主行:`inject: ['webServer', 'shell']`,只做
8
+ * `GET /fly/engagement/state|report`;**不注册工具**(bundle patch 里没有 `inject:['tools']` 的行);
9
+ * · `tools/index.js` = agent 平面的三个 `eng_*`,由 preset 按会话挂;
10
+ * · 两半共用 `lib/fold.mjs` 的投影口径 ⇒ 面板读数与 `eng_state` 永远同口径。
11
+ *
12
+ * 数据来源:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影=fold),
13
+ * 读文件走沙箱化的 `ctx.shell` 接缝(本包不 import `node:fs`)。
14
+ */
15
+
16
+ export const name = 'fly-engagement-panel'
17
+ export const inject = ['webServer', 'shell']
18
+
19
+ const BASE = '/fly/engagement'
20
+
21
+ function shq(value) {
22
+ return `'${String(value).replace(/'/g, "'\\''")}'`
23
+ }
24
+
25
+ async function readText(shell, file) {
26
+ const handle = await shell.execute(shell.resolve({ command: 'cat ' + shq(file) + ' 2>/dev/null || true' }))
27
+ const result = await handle.result()
28
+ if (result.sandbox?.denied) {
29
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
30
+ error.name = 'SandboxError'
31
+ throw error
32
+ }
33
+ return result.stdout.text
34
+ }
35
+
36
+ function json(res, status, payload) {
37
+ const body = JSON.stringify(payload, null, 2)
38
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
39
+ res.end(body)
40
+ }
41
+
42
+ /** 每条路由都自述(面板的"这个面板能问什么"表,与 netops 同一条纪律)。 */
43
+ export const endpoints = [
44
+ { path: `${BASE}/state`, summary: 'engagement 投影(与 eng_state 同口径):goal/节点/边/counts/dropped/refused/truncated' },
45
+ { path: `${BASE}/report`, summary: '全量 Markdown 报告(与 eng_report 同一份渲染)' },
46
+ ]
47
+
48
+ /**
49
+ * 处理一条请求。`?workspace=<绝对路径>` 必填(取不到会话 cwd 时由面板填 + 记忆)。
50
+ * 返回 `{ status, payload }`,由 transport 层写回 —— 纯函数,好测。
51
+ */
52
+ export async function answer(shell, url) {
53
+ const path = String(url ?? '/')
54
+ if (!path.startsWith(BASE)) return { status: 404, payload: { error: 'not found', endpoints } }
55
+ const query = new URLSearchParams(path.slice(path.indexOf('?') + 1))
56
+ const workspace = query.get('workspace')
57
+ if (workspace === null || workspace.trim() === '') {
58
+ return { status: 400, payload: { error: 'missing ?workspace=<absolute path>', endpoints } }
59
+ }
60
+ if (!workspace.startsWith('/')) {
61
+ return { status: 400, payload: { error: 'workspace 必须是绝对路径(收到 ' + workspace + ')', endpoints } }
62
+ }
63
+ const windowSize = Math.min(2000, Math.max(1, Math.round(Number(query.get('window') ?? 200) || 200)))
64
+ const file = ledgerPath(workspace)
65
+ const state = foldEvents((await readText(shell, file)).split('\n'))
66
+ if (path.startsWith(BASE + '/report')) {
67
+ return { status: 200, payload: { workspace, ledger: file, markdown: renderReport(workspace, state) } }
68
+ }
69
+ if (path.startsWith(BASE + '/state')) {
70
+ return {
71
+ status: 200,
72
+ payload: {
73
+ workspace,
74
+ ledger: file,
75
+ window: windowSize,
76
+ projection: windowView(state, windowSize),
77
+ endpoints,
78
+ },
79
+ }
80
+ }
81
+ return { status: 404, payload: { error: 'not found', endpoints } }
82
+ }
83
+
84
+ export function apply(ctx) {
85
+ const shell = ctx.shell
86
+ ctx.effect(
87
+ () => ctx.webServer.register({
88
+ kind: 'prefix',
89
+ path: BASE,
90
+ handler: async (req, res) => {
91
+ try {
92
+ const { status, payload } = await answer(shell, req.url ?? '/')
93
+ json(res, status, payload)
94
+ } catch (error) {
95
+ json(res, 500, { error: String(error?.message ?? error), endpoints })
96
+ }
97
+ },
98
+ }),
99
+ 'fly-engagement: ' + BASE + '/*',
100
+ )
101
+ }
package/lib/client.js ADDED
@@ -0,0 +1,400 @@
1
+ /* @dsh-fly/dsh-plugin-fly-engagement — browser half, built by scripts/build-plugin-client.mjs.
2
+ * src-hash: dfc489bb58d29d86b67f417b96d83c86f598b665a6426ab841f69745bd106120
3
+ * Factory-form CJS: the loader calls the factory with its platform `require`
4
+ * and memoizes the returned exports. Keep the banner so the loader id and the
5
+ * package that produced the bundle stay greppable in a live page. */
6
+ window.__ModuleLoader__.load({
7
+ id: "@dsh-fly/dsh-plugin-fly-engagement",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ var exports = module.exports;
11
+ "use strict";
12
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
13
+ const jsxRuntime = require("react/jsx-runtime");
14
+ const react = require("react");
15
+ const name = "fly-engagement-panel";
16
+ const inject = ["slots", "sidebarRightTabs"];
17
+ const PACKAGE = "@dsh-fly/dsh-plugin-fly-engagement";
18
+ const KIND = "fly.engagement";
19
+ const TAB_TITLE = "Engagement";
20
+ const TAB_DESCRIPTION = "目标 / 资产 / 漏洞 / 证据图";
21
+ const GUIDE_ORDER = 12;
22
+ const BASE = "/fly/engagement";
23
+ const STORE_KEY = "fly.engagement.workspace";
24
+ const PANEL_CSS = `/*
25
+ * fly-engagement 抽屉面板 —— **目标效果图的样式表**(2026-10-18,等用户判方向)
26
+ *
27
+ * 这张表就是实现时的对照物:类名 / token / 尺寸都按真面板的约定写('.fly-eg-*' 前缀,
28
+ * 与 shell 的 '.fly-*' 一族同形),所以"照图实现"不会漂移成另一套。
29
+ *
30
+ * token 纪律:这里因为要**独立渲染**(没有宿主 skin),把深色底值写进 ':root' 兜底;
31
+ * 实现时一律 'var(--dsw-alias-*, 兜底)' —— 真宿主会给出 token,兜底只在 mock/降级时生效。
32
+ * 面板框(rail 8px / desk 12px / 3px 亮缘)照 '.fly-panel' 的结构,不再发明第二套。
33
+ */
34
+ :root {
35
+ --dsw-alias-bg-base: #070b12;
36
+ --dsw-alias-bg-layer-1: rgba(255, 255, 255, 0.045);
37
+ --dsw-alias-bg-layer-2: rgba(255, 255, 255, 0.07);
38
+ --dsw-alias-fg-primary: #e8eef8;
39
+ --dsw-alias-fg-secondary: #9fb0c6;
40
+ --dsw-alias-fg-muted: #6b7d95;
41
+ --dsw-alias-border-l1: rgba(255, 255, 255, 0.10);
42
+ --fly-accent: #f0a55a;
43
+ --fly-accent-dim: rgba(240, 165, 90, 0.16);
44
+ --fly-critical: #ff5f6d;
45
+ --fly-high: #ff9b52;
46
+ --fly-medium: #e8c54a;
47
+ --fly-low: #6fc2ff;
48
+ --fly-info: #8fa3bd;
49
+ }
50
+
51
+ * { box-sizing: border-box; }
52
+ body {
53
+ margin: 0;
54
+ padding: var(--fly-desk, 12px);
55
+ background: #05080d;
56
+ font: 13px/1.5 -apple-system, "SF Pro Text", "PingFang SC", system-ui, sans-serif;
57
+ color: var(--dsw-alias-fg-primary);
58
+ }
59
+
60
+ /* ── 面板框:照 .fly-panel 的结构(rail 8px / 3px 亮缘 / 底部关系型亮缘) ── */
61
+ .fly-eg-panel {
62
+ --fly-rail: 8px;
63
+ width: 760px;
64
+ border-radius: 12px;
65
+ border: 1px solid var(--dsw-alias-border-l1);
66
+ background:
67
+ radial-gradient(900px 320px at 12% -12%, rgba(240, 165, 90, 0.10), transparent 70%),
68
+ linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0) 42%),
69
+ var(--dsw-alias-bg-base);
70
+ padding: var(--fly-rail) calc(var(--fly-rail) + 6px) calc(var(--fly-rail) + 8px);
71
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.06);
72
+ }
73
+
74
+ /* ── 头部:工作区 + goal + authorization ── */
75
+ .fly-eg-head { display: flex; flex-direction: column; gap: 6px; padding-bottom: 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
76
+ .fly-eg-title { display: flex; align-items: baseline; gap: 8px; }
77
+ .fly-eg-title h1 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: 0.2px; }
78
+ .fly-eg-badge { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: var(--fly-accent-dim); color: var(--fly-accent); border: 1px solid rgba(240, 165, 90, 0.35); }
79
+ .fly-eg-meta { font-size: 11.5px; color: var(--dsw-alias-fg-secondary); display: flex; flex-wrap: wrap; gap: 10px; }
80
+ .fly-eg-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--dsw-alias-fg-primary); }
81
+ .fly-eg-auth { font-size: 11.5px; color: var(--dsw-alias-fg-muted); }
82
+ .fly-eg-auth b { color: var(--fly-accent); font-weight: 600; }
83
+
84
+ /* ── 计数读数:counts + dropped/refused/truncated(截断显眼) ── */
85
+ .fly-eg-counters { display: flex; gap: 4px; margin: 8px 0; flex-wrap: wrap; align-items: center; }
86
+ .fly-eg-counter { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 6px; padding: 2px 6px; font-size: 10.5px; line-height: 1.5; color: var(--dsw-alias-fg-muted); }
87
+ .fly-eg-counter b { margin-left: 3px; font-size: 11px; }
88
+ .fly-eg-counter b { color: var(--dsw-alias-fg-primary); font-weight: 600; margin-left: 5px; }
89
+ .fly-eg-counter.is-warn { border-color: rgba(255, 95, 109, 0.45); background: rgba(255, 95, 109, 0.10); color: #ffb8be; }
90
+ .fly-eg-counter.is-warn b { color: #ffd7da; }
91
+
92
+ /* ── 表/图 切换(借 dsh-pentest AssetsView 的两模式) ── */
93
+ .fly-eg-tabs { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); margin-bottom: 10px; }
94
+ .fly-eg-tab { font-size: 11.5px; padding: 3px 10px; border-radius: 6px; color: var(--dsw-alias-fg-secondary); }
95
+ .fly-eg-tab.is-active { background: rgba(240, 165, 90, 0.18); color: var(--fly-accent); }
96
+
97
+ /* ── 分区标题 ── */
98
+ .fly-eg-section { margin-top: 12px; }
99
+ .fly-eg-section > h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--dsw-alias-fg-secondary); letter-spacing: 0.4px; text-transform: uppercase; }
100
+
101
+ /* ── 表 ── */
102
+ .fly-eg-table { width: 100%; border-collapse: collapse; font-size: 12px; }
103
+ .fly-eg-table th { text-align: left; font-weight: 500; color: var(--dsw-alias-fg-muted); font-size: 11px; padding: 4px 6px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
104
+ .fly-eg-table td { padding: 5px 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); vertical-align: top; }
105
+ .fly-eg-id { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-fg-muted); }
106
+ .fly-eg-src { font-size: 11px; color: var(--dsw-alias-fg-muted); }
107
+ .fly-eg-src b { color: var(--dsw-alias-fg-secondary); font-weight: 500; }
108
+ .fly-eg-sev { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 999px; display: inline-block; }
109
+ .fly-eg-sev.critical { color: #fff; background: var(--fly-critical); }
110
+ .fly-eg-sev.high { color: #1a0f06; background: var(--fly-high); }
111
+ .fly-eg-sev.medium { color: #1a1506; background: var(--fly-medium); }
112
+ .fly-eg-sev.low { color: #061019; background: var(--fly-low); }
113
+ .fly-eg-sev.info { color: #0b1017; background: var(--fly-info); }
114
+ .fly-eg-steps { margin: 4px 0 0 0; padding-left: 18px; color: var(--dsw-alias-fg-secondary); font-size: 11.5px; }
115
+ .fly-eg-steps code, .fly-eg-cmd { font-family: ui-monospace, Menlo, monospace; font-size: 11px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; }
116
+
117
+ /* ── 图区:静态分层 + 边胶囊 + 只读(借 xyflow 的形状,不引入拖拽) ── */
118
+ .fly-eg-graph { position: relative; height: 300px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: radial-gradient(600px 240px at 50% 0%, rgba(255,255,255,0.035), transparent 70%), var(--dsw-alias-bg-layer-1); overflow: hidden; }
119
+ .fly-eg-graph-hint { position: absolute; left: 8px; bottom: 6px; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
120
+ .fly-eg-graph-badge { position: absolute; right: 8px; top: 8px; font-size: 10.5px; padding: 2px 7px; border-radius: 999px; border: 1px solid rgba(255, 95, 109, 0.45); background: rgba(255, 95, 109, 0.12); color: #ffb8be; }
121
+ .fly-eg-node { position: absolute; transform: translate(-50%, -50%); border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); padding: 5px 9px; font-size: 11.5px; white-space: nowrap; }
122
+ .fly-eg-node.is-finding { border-color: rgba(255, 155, 82, 0.55); background: rgba(255, 155, 82, 0.14); color: #ffd7b0; }
123
+ .fly-eg-node.is-asset { border-color: rgba(111, 194, 255, 0.45); background: rgba(111, 194, 255, 0.12); color: #cbe8ff; }
124
+ .fly-eg-edge { position: absolute; height: 1.5px; background: rgba(240, 165, 90, 0.55); transform-origin: 0 50%; }
125
+ .fly-eg-edge-cap { position: absolute; font-size: 10px; color: var(--fly-accent); background: rgba(7, 11, 18, 0.85); padding: 0 4px; border-radius: 4px; border: 1px solid rgba(240, 165, 90, 0.3); }
126
+
127
+ /* ── 报告区 ── */
128
+ .fly-eg-report { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); padding: 8px 10px; font-size: 11.5px; color: var(--dsw-alias-fg-secondary); }
129
+ .fly-eg-report-actions { display: flex; gap: 6px; margin-top: 8px; }
130
+ .fly-eg-btn { font-size: 11.5px; padding: 3px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-fg-primary); }
131
+
132
+ /* 图区的线走 SVG(按节点实测中心算),不用手摆坐标 —— 实现时同样按数据算 */
133
+ .fly-eg-wires { position: absolute; inset: 0; pointer-events: none; }
134
+
135
+ /* 连线走**折线**(elbow):横向只占层间那一段,纵向在间隙里走 —— 比直线省横向空间。
136
+ 实现时同样用 SVG path(M/H/V 折角 + stroke-linejoin:round)。 */
137
+ .fly-eg-wire { fill: none; stroke: rgba(240, 165, 90, 0.55); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; }
138
+
139
+ /* ── 事实按来源工具折叠(<details>,缺省收起:长会话省地方) ── */
140
+ .fly-eg-group { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); margin-bottom: 4px; }
141
+ .fly-eg-group > summary { cursor: default; list-style: none; padding: 4px 8px; font-size: 11.5px; color: var(--dsw-alias-fg-secondary); display: flex; align-items: center; gap: 6px; }
142
+ .fly-eg-group > summary::-webkit-details-marker { display: none; }
143
+ .fly-eg-group > summary::before { content: '▸'; color: var(--dsw-alias-fg-muted); font-size: 10px; }
144
+ .fly-eg-group[open] > summary::before { content: '▾'; }
145
+ .fly-eg-group > summary b { color: var(--dsw-alias-fg-primary); font-weight: 600; }
146
+ .fly-eg-group > summary .fly-eg-count { margin-left: auto; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
147
+ .fly-eg-group table { margin: 0 0 2px; }
148
+
149
+ .fly-eg-input { width: 100%; font: inherit; font-size: 11.5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-fg-primary); }
150
+ .fly-eg-error { margin: 8px 0; padding: 6px 9px; border-radius: 8px; border: 1px solid rgba(255,95,109,0.45); background: rgba(255,95,109,0.10); color: #ffb8be; font-size: 11.5px; }
151
+ .fly-eg-empty { margin: 14px 0; color: var(--dsw-alias-fg-muted); font-size: 11.5px; }
152
+ `;
153
+ async function getJson(path) {
154
+ const response = await fetch(path, { headers: { accept: "application/json" } });
155
+ if (!response.ok) throw new Error(path + " → HTTP " + response.status);
156
+ return await response.json();
157
+ }
158
+ function Graph({ nodes, edges, kind }) {
159
+ const byLayer = /* @__PURE__ */ new Map();
160
+ for (const node of nodes) {
161
+ const list = byLayer.get(node.layer) ?? [];
162
+ list.push(node);
163
+ byLayer.set(node.layer, list);
164
+ }
165
+ const layers = [...byLayer.keys()].sort((a, b) => a - b);
166
+ const widest = Math.max(120, ...nodes.map((node) => node.label.length * 6.6 + 20));
167
+ const gap = 78;
168
+ const xOf = /* @__PURE__ */ new Map();
169
+ let cursor = 60;
170
+ for (const layer of layers) {
171
+ for (const node of byLayer.get(layer) ?? []) xOf.set(node.id, cursor + widest / 2);
172
+ cursor += widest + gap;
173
+ }
174
+ const height = Math.max(180, ...layers.map((layer) => (byLayer.get(layer) ?? []).length * 54));
175
+ const yOf = /* @__PURE__ */ new Map();
176
+ for (const layer of layers) {
177
+ const list = byLayer.get(layer) ?? [];
178
+ list.forEach((node, index) => yOf.set(node.id, height / (list.length + 1) * (index + 1)));
179
+ }
180
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-graph", style: { height: height + 60 }, children: [
181
+ /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "fly-eg-wires", width: "100%", height: "100%", children: edges.filter((edge) => xOf.has(edge.from) && xOf.has(edge.to)).map((edge) => {
182
+ const x1 = (xOf.get(edge.from) ?? 0) + widest / 2;
183
+ const x2 = (xOf.get(edge.to) ?? 0) - widest / 2;
184
+ const midX = (x1 + x2) / 2;
185
+ return /* @__PURE__ */ jsxRuntime.jsx("path", { className: "fly-eg-wire", d: "M " + x1 + " " + yOf.get(edge.from) + " H " + midX + " V " + yOf.get(edge.to) + " H " + x2 }, kind + edge.from + edge.to);
186
+ }) }),
187
+ nodes.map((node) => /* @__PURE__ */ jsxRuntime.jsx(
188
+ "div",
189
+ {
190
+ className: "fly-eg-node" + (node.tone === "finding" ? " is-finding" : node.tone === "asset" ? " is-asset" : ""),
191
+ style: { left: xOf.get(node.id), top: yOf.get(node.id) },
192
+ children: node.label
193
+ },
194
+ node.id
195
+ )),
196
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-graph-hint", children: "折线走层间间隙 · 静态分层 · 只读" })
197
+ ] });
198
+ }
199
+ function Panel() {
200
+ const [workspace, setWorkspace] = react.useState(() => localStorage.getItem(STORE_KEY) ?? "");
201
+ const [state, setState] = react.useState(null);
202
+ const [error, setError] = react.useState("");
203
+ const [view, setView] = react.useState("table");
204
+ const [report, setReport] = react.useState("");
205
+ const load = react.useCallback(async (target) => {
206
+ if (target.trim() === "") return;
207
+ setError("");
208
+ try {
209
+ const [next, rendered] = await Promise.all([
210
+ getJson(BASE + "/state?workspace=" + encodeURIComponent(target)),
211
+ getJson(BASE + "/report?workspace=" + encodeURIComponent(target)).catch(() => ({ markdown: "" }))
212
+ ]);
213
+ setState(next);
214
+ setReport(rendered.markdown);
215
+ localStorage.setItem(STORE_KEY, target);
216
+ } catch (failure) {
217
+ setError(String(failure.message ?? failure));
218
+ setState(null);
219
+ }
220
+ }, []);
221
+ react.useEffect(() => {
222
+ void load(workspace);
223
+ }, [load, workspace]);
224
+ const projection = state?.projection;
225
+ const groups = react.useMemo(() => {
226
+ const map = /* @__PURE__ */ new Map();
227
+ for (const fact of projection?.facts ?? []) {
228
+ const tool = fact.source?.tool ?? "(unknown tool)";
229
+ map.set(tool, [...map.get(tool) ?? [], fact]);
230
+ }
231
+ return [...map.entries()].sort((a, b) => b[1].length - a[1].length);
232
+ }, [projection]);
233
+ return /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "fly-eg-panel", children: [
234
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-head", children: [
235
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-title", children: [
236
+ /* @__PURE__ */ jsxRuntime.jsx("h1", { children: "Engagement" }),
237
+ projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-badge", children: projection.goal.target })
238
+ ] }),
239
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-meta", children: /* @__PURE__ */ jsxRuntime.jsx(
240
+ "input",
241
+ {
242
+ className: "fly-eg-input",
243
+ value: workspace,
244
+ placeholder: "工作区绝对路径(记录在 <workspace>/.fly-engagement/events.jsonl)",
245
+ onChange: (event) => setWorkspace(event.target.value),
246
+ onKeyDown: (event) => {
247
+ if (event.key === "Enter") void load(workspace);
248
+ }
249
+ }
250
+ ) }),
251
+ projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
252
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-meta", children: /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
253
+ "objective: ",
254
+ projection.goal.objective
255
+ ] }) }),
256
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-auth", children: [
257
+ "authorization: ",
258
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: projection.goal.authorization === void 0 || projection.goal.authorization === "" ? "(未记录)" : projection.goal.authorization }),
259
+ " · 审计事实,不是门禁"
260
+ ] })
261
+ ] })
262
+ ] }),
263
+ error !== "" ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-error", children: error }) : null,
264
+ projection === void 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-empty", children: "还没读取记录(填工作区后回车)" }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
265
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-counters", children: [
266
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter", children: [
267
+ "facts",
268
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: projection.counts.facts })
269
+ ] }),
270
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter", children: [
271
+ "findings",
272
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: projection.counts.findings })
273
+ ] }),
274
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter", children: [
275
+ "assets",
276
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: projection.counts.assets })
277
+ ] }),
278
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter", children: [
279
+ "edges",
280
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: projection.counts.edges })
281
+ ] }),
282
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter" + (projection.dropped > 0 ? " is-warn" : ""), children: [
283
+ "dropped",
284
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: projection.dropped })
285
+ ] }),
286
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter" + (projection.refused > 0 ? " is-warn" : ""), children: [
287
+ "refused",
288
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: projection.refused })
289
+ ] })
290
+ ] }),
291
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-tabs", children: [
292
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-tab" + (view === "table" ? " is-active" : ""), onClick: () => setView("table"), children: "表" }),
293
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-tab" + (view === "graph" ? " is-active" : ""), onClick: () => setView("graph"), children: "图" })
294
+ ] }),
295
+ view === "table" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
296
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
297
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Assets" }),
298
+ /* @__PURE__ */ jsxRuntime.jsx("table", { className: "fly-eg-table", children: /* @__PURE__ */ jsxRuntime.jsx("tbody", { children: projection.assets.map((asset) => /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
299
+ /* @__PURE__ */ jsxRuntime.jsx("td", { className: "fly-eg-id", children: asset.id }),
300
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: asset.type }),
301
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: asset.value }),
302
+ /* @__PURE__ */ jsxRuntime.jsx("td", { className: "fly-eg-src", children: asset.parentId ?? "—" })
303
+ ] }, asset.id)) }) })
304
+ ] }),
305
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
306
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Findings" }),
307
+ /* @__PURE__ */ jsxRuntime.jsx("table", { className: "fly-eg-table", children: /* @__PURE__ */ jsxRuntime.jsx("tbody", { children: projection.findings.map((finding) => /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
308
+ /* @__PURE__ */ jsxRuntime.jsx("td", { className: "fly-eg-id", children: finding.id }),
309
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-sev " + (finding.severity ?? "info"), children: finding.severity ?? "info" }) }),
310
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: finding.title }),
311
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("ol", { className: "fly-eg-steps", children: finding.reproducibleSteps.map((step, index) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: step }) }, index)) }) })
312
+ ] }, finding.id)) }) })
313
+ ] }),
314
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
315
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Facts · by source tool" }),
316
+ groups.map(([tool, facts], index) => /* @__PURE__ */ jsxRuntime.jsxs("details", { className: "fly-eg-group", open: index === 0, children: [
317
+ /* @__PURE__ */ jsxRuntime.jsxs("summary", { children: [
318
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: tool }),
319
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-count", children: [
320
+ facts.length,
321
+ " 条"
322
+ ] })
323
+ ] }),
324
+ /* @__PURE__ */ jsxRuntime.jsx("table", { className: "fly-eg-table", children: /* @__PURE__ */ jsxRuntime.jsx("tbody", { children: facts.map((fact) => /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
325
+ /* @__PURE__ */ jsxRuntime.jsx("td", { className: "fly-eg-id", children: fact.id }),
326
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: fact.detail })
327
+ ] }, fact.id)) }) })
328
+ ] }, tool))
329
+ ] }),
330
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
331
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Report" }),
332
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-report", children: report === "" ? "(还没有可渲染的报告)" : report.slice(0, 2e3) })
333
+ ] })
334
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
335
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
336
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Evidence graph" }),
337
+ /* @__PURE__ */ jsxRuntime.jsx(
338
+ Graph,
339
+ {
340
+ kind: "proves",
341
+ nodes: [
342
+ ...projection.facts.map((fact, index) => ({ id: fact.id, label: fact.id + " " + (fact.kind ?? "info"), layer: 0, tone: void 0 })),
343
+ ...projection.findings.map((finding) => ({ id: finding.id, label: finding.id + " " + finding.title, layer: 1, tone: "finding" }))
344
+ ],
345
+ edges: projection.edges.filter((edge) => edge.kind === "proves" || edge.kind === "derived_from")
346
+ }
347
+ )
348
+ ] }),
349
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
350
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Asset tree" }),
351
+ /* @__PURE__ */ jsxRuntime.jsx(
352
+ Graph,
353
+ {
354
+ kind: "parent",
355
+ nodes: projection.assets.map((asset) => ({ id: asset.id, label: asset.value + " (" + asset.type + ")", layer: asset.parentId === void 0 ? 0 : 1, tone: "asset" })),
356
+ edges: projection.edges.filter((edge) => edge.kind === "parent")
357
+ }
358
+ )
359
+ ] })
360
+ ] })
361
+ ] })
362
+ ] });
363
+ }
364
+ function EngagementMark({ size = 18, active = false }) {
365
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { className: "fly-mark", "data-active": active ? "true" : "false", width: size, height: size, viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", children: [
366
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "6", cy: "6", r: "2.2", stroke: "currentColor", strokeWidth: "1.6" }),
367
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "18", cy: "12", r: "2.2", stroke: "currentColor", strokeWidth: "1.6" }),
368
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "6", cy: "18", r: "2.2", stroke: "currentColor", strokeWidth: "1.6" }),
369
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 7l8 4M8 17l8-4", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round" })
370
+ ] });
371
+ }
372
+ function apply(ctx) {
373
+ ctx.effect(() => ctx.sidebarRightTabs.register({
374
+ id: PACKAGE,
375
+ kind: KIND,
376
+ title: () => TAB_TITLE,
377
+ guide: [{ id: "open", order: GUIDE_ORDER, title: () => TAB_TITLE, description: () => TAB_DESCRIPTION, icon: EngagementMark }]
378
+ }), "fly-engagement-panel: drawer type");
379
+ ctx.effect(() => {
380
+ const tag = document.createElement("style");
381
+ tag.setAttribute("data-plugin", PACKAGE);
382
+ tag.textContent = PANEL_CSS;
383
+ document.head.appendChild(tag);
384
+ return () => {
385
+ tag.remove();
386
+ };
387
+ }, "fly-engagement-panel: panel stylesheet");
388
+ ctx.effect(() => ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register(
389
+ { name: "sidebar.right.pane.tab", key: PACKAGE, inject: () => ({}) },
390
+ Panel
391
+ )), "fly-engagement-panel: drawer body");
392
+ }
393
+ exports.EngagementMark = EngagementMark;
394
+ exports.apply = apply;
395
+ exports.inject = inject;
396
+ exports.name = name;
397
+
398
+ return module.exports;
399
+ },
400
+ });
package/lib/fold.mjs ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * engagement 记录的**纯函数层** —— 工具半边(agent 平面)与面板半边(宿主行)共用一份。
3
+ *
4
+ * 为什么单独成文件:这两半是两个挂载点(`tools/index.js` 由 preset 按会话挂;根 `index.js`
5
+ * 是宿主平面的面板行),**投影口径必须逐字一致**,否则面板上的计数和 `eng_state` 会讲两个故事。
6
+ * 同包内 import ⇒ 不需要"两份逐字节相同"的钉子(那是跨包才付的代价)。
7
+ */
8
+
9
+ export const FACT_KINDS = ['net', 'service', 'http', 'binary', 'code', 'vuln', 'info']
10
+ export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info']
11
+ export const ASSET_TYPES = ['host', 'ip', 'port', 'service', 'url', 'file', 'binary', 'process']
12
+ export const EDGE_KINDS = ['parent', 'proves', 'derived_from']
13
+
14
+ export const LEDGER_DIR = '.fly-engagement'
15
+ export const LEDGER_FILE = 'events.jsonl'
16
+
17
+ /** `<workspace>/.fly-engagement/events.jsonl`(工作区路径尾斜杠无所谓)。 */
18
+ export function ledgerPath(workspace) {
19
+ return String(workspace).replace(/\/+$/u, '') + '/' + LEDGER_DIR + '/' + LEDGER_FILE
20
+ }
21
+
22
+ /**
23
+ * 折叠事件流 ⇒ 完整状态(**确定性**:同一个文件永远折出同一张图)。
24
+ * 坏引用计入 `refused`(不是静默丢弃)。
25
+ */
26
+ export function foldEvents(lines) {
27
+ const state = { goal: null, facts: [], findings: [], assets: [], edges: [], refused: 0 }
28
+ const known = () => new Set([...state.facts.map(row => row.id), ...state.findings.map(row => row.id), ...state.assets.map(row => row.id)])
29
+ for (const line of lines) {
30
+ if (line.trim() === '') continue
31
+ let event
32
+ try {
33
+ event = JSON.parse(line)
34
+ } catch {
35
+ state.refused += 1
36
+ continue
37
+ }
38
+ if (event.op === 'goal') {
39
+ if (state.goal !== null) { state.refused += 1; continue }
40
+ state.goal = event.goal
41
+ continue
42
+ }
43
+ if (event.op !== 'record') continue
44
+ const ids = known()
45
+ for (const asset of event.assets ?? []) {
46
+ if (asset.parentId !== undefined && asset.parentId !== '' && !ids.has(asset.parentId)) { state.refused += 1; continue }
47
+ state.assets.push(asset)
48
+ ids.add(asset.id)
49
+ }
50
+ for (const fact of event.facts ?? []) {
51
+ state.facts.push(fact)
52
+ ids.add(fact.id)
53
+ }
54
+ for (const finding of event.findings ?? []) {
55
+ if (finding.affectedAssetId !== undefined && finding.affectedAssetId !== '' && !ids.has(finding.affectedAssetId)) { state.refused += 1; continue }
56
+ state.findings.push(finding)
57
+ ids.add(finding.id)
58
+ }
59
+ for (const edge of event.edges ?? []) {
60
+ if (!ids.has(edge.from) || !ids.has(edge.to)) { state.refused += 1; continue }
61
+ state.edges.push(edge)
62
+ }
63
+ }
64
+ return state
65
+ }
66
+
67
+ /**
68
+ * 窗口视图:每张表留最新 `windowSize` 条;`dropped` 是被逐出的条数。
69
+ * `counts` 报**全量**、窗口只影响 rows —— 两个读数都要在(面板与 `eng_state` 同口径)。
70
+ */
71
+ export function windowView(state, windowSize) {
72
+ const cut = rows => rows.length <= windowSize
73
+ ? { rows, dropped: 0 }
74
+ : { rows: rows.slice(rows.length - windowSize), dropped: rows.length - windowSize }
75
+ const facts = cut(state.facts)
76
+ const findings = cut(state.findings)
77
+ const assets = cut(state.assets)
78
+ const kept = new Set([...facts.rows, ...findings.rows, ...assets.rows].map(row => row.id))
79
+ const dropped = facts.dropped + findings.dropped + assets.dropped
80
+ return {
81
+ goal: state.goal,
82
+ facts: facts.rows,
83
+ findings: findings.rows,
84
+ assets: assets.rows,
85
+ edges: state.edges.filter(edge => kept.has(edge.from) && kept.has(edge.to)),
86
+ counts: { facts: state.facts.length, findings: state.findings.length, assets: state.assets.length, edges: state.edges.length },
87
+ dropped,
88
+ refused: state.refused,
89
+ truncated: dropped > 0,
90
+ }
91
+ }
92
+
93
+ /** 下一个确定性 id `<kind>-<n>`。 */
94
+ export function nextId(prefix, rows) {
95
+ let max = 0
96
+ for (const row of rows) {
97
+ const match = new RegExp('^' + prefix + '-(\\d+)$', 'u').exec(row.id)
98
+ if (match !== null) max = Math.max(max, Number(match[1]))
99
+ }
100
+ return prefix + '-' + (max + 1)
101
+ }
102
+
103
+ /** 从全量状态渲染 Markdown 报告(`eng_report` 与 `/fly/engagement/report` 同一份)。 */
104
+ export function renderReport(workspace, state) {
105
+ const lines = []
106
+ lines.push('# Engagement report')
107
+ lines.push('')
108
+ lines.push('- workspace: `' + workspace + '`')
109
+ lines.push('- ledger: `' + ledgerPath(workspace) + '`')
110
+ lines.push('- counts: facts=' + state.facts.length + ' findings=' + state.findings.length + ' assets=' + state.assets.length + ' edges=' + state.edges.length)
111
+ if (state.refused > 0) lines.push('- refused writes: ' + state.refused + '(引用不存在的 id,已拒绝)')
112
+ lines.push('', '## Goal', '')
113
+ if (state.goal === null) {
114
+ lines.push('_未设置(还没有 eng_record 带 goal)_')
115
+ } else {
116
+ lines.push('- target: ' + state.goal.target)
117
+ lines.push('- objective: ' + state.goal.objective)
118
+ lines.push('- authorization: ' + (state.goal.authorization === undefined || state.goal.authorization === '' ? '_未记录_(这是审计事实,不是门禁)' : state.goal.authorization))
119
+ }
120
+ lines.push('', '## Assets')
121
+ if (state.assets.length === 0) lines.push('', '_none_')
122
+ for (const asset of state.assets) {
123
+ const parent = asset.parentId === undefined || asset.parentId === '' ? '' : ' ← parent `' + asset.parentId + '`'
124
+ lines.push('- `' + asset.id + '` [' + asset.type + '] ' + asset.value + parent)
125
+ }
126
+ lines.push('', '## Findings')
127
+ if (state.findings.length === 0) lines.push('', '_none_')
128
+ for (const finding of state.findings) {
129
+ lines.push('', '### `' + finding.id + '` ' + finding.title + ' — **' + (finding.severity ?? 'info') + '**')
130
+ if (finding.description !== undefined && finding.description !== '') lines.push('', finding.description)
131
+ if (finding.affectedAssetId !== undefined && finding.affectedAssetId !== '') lines.push('', 'affected asset: `' + finding.affectedAssetId + '`')
132
+ lines.push('', 'reproducible steps (' + finding.reproducibleSteps.length + '):')
133
+ for (const [index, step] of finding.reproducibleSteps.entries()) lines.push((index + 1) + '. ' + step)
134
+ }
135
+ lines.push('', '## Facts')
136
+ if (state.facts.length === 0) lines.push('', '_none_')
137
+ for (const fact of state.facts) {
138
+ const source = fact.source === undefined ? '' : ' _(source: ' + [fact.source.tool, fact.source.operation].filter(Boolean).join(' · ') + ')_'
139
+ lines.push('- `' + fact.id + '` [' + (fact.kind ?? 'info') + ']' + (fact.target === undefined ? '' : ' ' + fact.target) + ' — ' + fact.detail + source)
140
+ }
141
+ lines.push('', '## Edges')
142
+ if (state.edges.length === 0) lines.push('', '_none_')
143
+ for (const edge of state.edges) lines.push('- `' + edge.from + '` -[' + edge.kind + ']→ `' + edge.to + '`')
144
+ lines.push('')
145
+ return lines.join('\n')
146
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@dsh-fly/dsh-plugin-fly-engagement",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "tools/index.js",
9
+ "lib/fold.mjs",
10
+ "lib/client.js",
11
+ "cordis.patch.yml"
12
+ ],
13
+ "peerDependencies": {
14
+ "@deepseek-ai/cordis": "^4.0.2",
15
+ "@deepseek-ai/dsh-shell": "^0.2.1-rc.2",
16
+ "@deepseek-ai/dsh-tools": "^0.2.1-rc.2"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/ethanchen669/dsh-plugin-fly-engagement.git"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "exports": {
27
+ ".": "./index.js",
28
+ "./tools/index.js": "./tools/index.js",
29
+ "./lib/fold.mjs": "./lib/fold.mjs",
30
+ "./client": "./lib/client.js",
31
+ "./cordis.patch.yml": "./cordis.patch.yml",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "platform": "web",
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
42
+ ]
43
+ }
44
+ },
45
+ "scripts": {
46
+ "verify": "node scripts/verify.mjs",
47
+ "test": "node --test tests/*.spec.mjs",
48
+ "build:client": "node scripts/build-client.mjs"
49
+ },
50
+ "description": "DSH Fly engagement 记录面:eng_record/eng_state/eng_report + 抽屉面板(工作区 JSONL + 投影)",
51
+ "homepage": "https://github.com/ethanchen669/dsh-plugin-fly-engagement#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/ethanchen669/dsh-plugin-fly-engagement/issues"
54
+ },
55
+ "devDependencies": {
56
+ "vite": "^5.4.21"
57
+ }
58
+ }
package/tools/index.js ADDED
@@ -0,0 +1,230 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import {
3
+ ASSET_TYPES, EDGE_KINDS, FACT_KINDS, SEVERITIES,
4
+ foldEvents, ledgerPath, nextId, renderReport, windowView,
5
+ } from '../lib/fold.mjs'
6
+
7
+ /**
8
+ * fly-engagement —— **agent 平面**的工具半边(preset 行:`dsh-plugin-fly-engagement/tools/index.js`)。
9
+ *
10
+ * 记录写进工作区:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影=fold)。
11
+ * 面板半边是**同一个包的根 `index.js`**(宿主行,`inject:['webServer','shell']`)——
12
+ * 两个挂载点共用 `lib/fold.mjs` 的投影口径,所以面板读数与 `eng_state` 永远同口径。
13
+ *
14
+ * **不写任何自定义会话事件**(AGENTS.md 毒药条);文件读写全走 `ctx.shell` 接缝。
15
+ */
16
+
17
+ export const name = 'fly-engagement-tools'
18
+ export const inject = ['tools', 'shell']
19
+
20
+ function argumentError(message) {
21
+ const error = new Error(message)
22
+ error.name = 'ArgumentError'
23
+ return error
24
+ }
25
+
26
+ function shq(value) {
27
+ return `'${String(value).replace(/'/g, "'\\''")}'`
28
+ }
29
+
30
+ /** 与五个安全包同一形状:`resolve()` + `execute()`,handle 上 `result` 是方法。 */
31
+ async function runCmd(shell, argv, exec, opts = {}) {
32
+ const result = await (await shell.execute(shell.resolve({
33
+ command: argv.map(shq).join(' '),
34
+ ...opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {},
35
+ signal: exec.signal,
36
+ }))).result()
37
+ if (result.aborted) {
38
+ const error = new Error('tool call aborted')
39
+ error.name = 'AbortError'
40
+ throw error
41
+ }
42
+ if (result.sandbox?.denied) {
43
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
44
+ error.name = 'SandboxError'
45
+ throw error
46
+ }
47
+ return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode }
48
+ }
49
+
50
+ async function appendText(shell, file, text, exec) {
51
+ const encoded = Buffer.from(text, 'utf8').toString('base64')
52
+ return await runCmd(shell, ['sh', '-c', 'printf %s ' + shq(encoded) + ' | base64 -d >> ' + shq(file)], exec)
53
+ }
54
+
55
+ async function readText(shell, file, exec) {
56
+ const run = await runCmd(shell, ['sh', '-c', 'cat ' + shq(file) + ' 2>/dev/null || true'], exec)
57
+ return run.stdout
58
+ }
59
+
60
+ function normalizeRecord(args) {
61
+ const goal = args.goal === undefined ? null : args.goal
62
+ if (goal !== null) {
63
+ if (typeof goal !== 'object') throw argumentError('goal 必须是一个对象')
64
+ if (typeof goal.target !== 'string' || goal.target.trim() === '') throw argumentError('goal.target 必填(目标)')
65
+ if (typeof goal.objective !== 'string' || goal.objective.trim() === '') throw argumentError('goal.objective 必填(目标/完成标准)')
66
+ }
67
+ const facts = Array.isArray(args.facts) ? args.facts : []
68
+ const findings = Array.isArray(args.findings) ? args.findings : []
69
+ const assets = Array.isArray(args.assets) ? args.assets : []
70
+ const edges = Array.isArray(args.edges) ? args.edges : []
71
+ for (const [index, fact] of facts.entries()) {
72
+ if (typeof fact?.detail !== 'string' || fact.detail.trim() === '') throw argumentError('facts[' + index + '].detail 必填(确认到的证据)')
73
+ if (fact.kind !== undefined && !FACT_KINDS.includes(fact.kind)) throw argumentError('facts[' + index + '].kind 只能是 ' + FACT_KINDS.join('/'))
74
+ }
75
+ for (const [index, finding] of findings.entries()) {
76
+ if (typeof finding?.title !== 'string' || finding.title.trim() === '') throw argumentError('findings[' + index + '].title 必填')
77
+ if (finding.severity !== undefined && !SEVERITIES.includes(finding.severity)) throw argumentError('findings[' + index + '].severity 只能是 ' + SEVERITIES.join('/'))
78
+ if (!Array.isArray(finding.reproducibleSteps) || finding.reproducibleSteps.length === 0) {
79
+ throw argumentError('findings[' + index + '].reproducibleSteps 必填且至少 1 条(没有复现步骤的漏洞不算记录)')
80
+ }
81
+ if (finding.reproducibleSteps.some(step => typeof step !== 'string' || step.trim() === '')) {
82
+ throw argumentError('findings[' + index + '].reproducibleSteps 里每条都必须是非空字符串')
83
+ }
84
+ }
85
+ for (const [index, asset] of assets.entries()) {
86
+ if (!ASSET_TYPES.includes(asset?.type)) throw argumentError('assets[' + index + '].type 只能是 ' + ASSET_TYPES.join('/'))
87
+ if (typeof asset.value !== 'string' || asset.value.trim() === '') throw argumentError('assets[' + index + '].value 必填')
88
+ }
89
+ for (const [index, edge] of edges.entries()) {
90
+ if (!EDGE_KINDS.includes(edge?.kind)) throw argumentError('edges[' + index + '].kind 只能是 ' + EDGE_KINDS.join('/'))
91
+ if (typeof edge.from !== 'string' || edge.from.trim() === '') throw argumentError('edges[' + index + '].from 必填')
92
+ if (typeof edge.to !== 'string' || edge.to.trim() === '') throw argumentError('edges[' + index + '].to 必填')
93
+ }
94
+ return { goal, facts, findings, assets, edges }
95
+ }
96
+
97
+ const workspaceParam = { type: 'string', required: true, description: 'Absolute workspace path; the ledger lives in <workspace>/.fly-engagement/events.jsonl' }
98
+
99
+ export function apply(ctx) {
100
+ const shell = ctx.shell
101
+
102
+ ctx.tools.register(defineTool({
103
+ name: 'eng_record',
104
+ description: 'Record engagement evidence into the workspace ledger: goal (once), facts, findings (reproducible steps required), assets and edges. Returns deterministic ids for later reference.',
105
+ parameters: {
106
+ workspace: workspaceParam,
107
+ goal: { type: 'object', additionalProperties: true, description: 'Once per engagement: { target, objective, authorization? }' },
108
+ facts: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Facts: { kind?, target?, detail (required), confidence?, source? }' },
109
+ findings: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Findings: { title, severity?, description?, reproducibleSteps (required), affectedAssetId? }' },
110
+ assets: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Assets: { type, value, parentId?, meta? }' },
111
+ edges: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Edges: { kind: parent|proves|derived_from, from, to }' },
112
+ },
113
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
114
+ async execute(args, exec) {
115
+ if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_record 需要 workspace(绝对路径)')
116
+ const normalized = normalizeRecord(args)
117
+ if (normalized.goal === null && normalized.facts.length === 0 && normalized.findings.length === 0 && normalized.assets.length === 0 && normalized.edges.length === 0) {
118
+ throw argumentError('eng_record 一次要写至少一样东西(goal / facts / findings / assets / edges 全空)')
119
+ }
120
+ const file = ledgerPath(args.workspace)
121
+ const dir = file.slice(0, file.lastIndexOf('/'))
122
+ await runCmd(shell, ['sh', '-c', 'mkdir -p ' + shq(dir)], exec)
123
+ const existing = foldEvents((await readText(shell, file, exec)).split('\n'))
124
+ if (normalized.goal !== null && existing.goal !== null) {
125
+ throw argumentError('这个 engagement 已经有 goal 了(target=' + existing.goal.target + ')—— 一个 engagement 只有一个 goal;要重新开始请换一个工作区')
126
+ }
127
+ const ids = new Set([...existing.facts.map(row => row.id), ...existing.findings.map(row => row.id), ...existing.assets.map(row => row.id)])
128
+ const created = { facts: [], findings: [], assets: [], edges: [] }
129
+ for (const asset of normalized.assets) {
130
+ if (asset.parentId !== undefined && asset.parentId !== '' && !ids.has(asset.parentId)) {
131
+ throw argumentError('assets.parentId=' + asset.parentId + ' 不存在(只能引用已记录的资产;同一次调用里请先写父资产)')
132
+ }
133
+ const row = { id: nextId('asset', [...existing.assets, ...created.assets]), ...asset }
134
+ created.assets.push(row)
135
+ ids.add(row.id)
136
+ }
137
+ for (const fact of normalized.facts) {
138
+ const row = { id: nextId('fact', [...existing.facts, ...created.facts]), ...fact }
139
+ created.facts.push(row)
140
+ ids.add(row.id)
141
+ }
142
+ for (const finding of normalized.findings) {
143
+ if (finding.affectedAssetId !== undefined && finding.affectedAssetId !== '' && !ids.has(finding.affectedAssetId)) {
144
+ throw argumentError('findings.affectedAssetId=' + finding.affectedAssetId + ' 不存在(只能引用已记录的资产)')
145
+ }
146
+ const row = { id: nextId('finding', [...existing.findings, ...created.findings]), ...finding }
147
+ created.findings.push(row)
148
+ ids.add(row.id)
149
+ }
150
+ for (const edge of normalized.edges) {
151
+ if (!ids.has(edge.from) || !ids.has(edge.to)) {
152
+ throw argumentError('edges 的端点必须已记录(' + edge.from + ' → ' + edge.to + ');未知 id 一律拒绝,不静默丢弃')
153
+ }
154
+ created.edges.push({ ...edge })
155
+ }
156
+ const events = []
157
+ if (normalized.goal !== null) events.push({ op: 'goal', at: new Date().toISOString(), goal: normalized.goal })
158
+ events.push({ op: 'record', at: new Date().toISOString(), facts: created.facts, findings: created.findings, assets: created.assets, edges: created.edges })
159
+ await appendText(shell, file, events.map(event => JSON.stringify(event)).join('\n') + '\n', exec)
160
+ return JSON.stringify({
161
+ tool: 'eng_record',
162
+ target: file,
163
+ operation: 'append to the workspace ledger (goal/facts/findings/assets/edges)',
164
+ observed: {
165
+ created: { facts: created.facts.length, findings: created.findings.length, assets: created.assets.length, edges: created.edges.length },
166
+ ids: { facts: created.facts.map(row => row.id), findings: created.findings.map(row => row.id), assets: created.assets.map(row => row.id) },
167
+ goal: normalized.goal === null ? null : normalized.goal.target,
168
+ },
169
+ confidence: 'observed',
170
+ limitations: [
171
+ '记录是 append-only 的:写错要改请换工作区重来(v1 不做改写/删除)',
172
+ 'id 是确定性 <kind>-<n>(按会话内的记录次序),跨调用可引用;同一次调用内不能互相引用(先父后子)',
173
+ 'ledger 在 <workspace>/.fly-engagement/events.jsonl —— 工作区换一个就是另一个 engagement',
174
+ ],
175
+ }, null, 2)
176
+ },
177
+ }))
178
+
179
+ ctx.tools.register(defineTool({
180
+ name: 'eng_state',
181
+ description: 'Read the engagement projection: goal, nodes, edges, counts, and the window bookkeeping (dropped/refused/truncated).',
182
+ parameters: {
183
+ workspace: workspaceParam,
184
+ window: { type: 'number', description: 'Node window per table (default 200)' },
185
+ },
186
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
187
+ async execute(args, exec) {
188
+ if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_state 需要 workspace(绝对路径)')
189
+ const windowSize = Math.min(2000, Math.max(1, Math.round(Number(args.window ?? 200) || 200)))
190
+ const file = ledgerPath(args.workspace)
191
+ const view = windowView(foldEvents((await readText(shell, file, exec)).split('\n')), windowSize)
192
+ return JSON.stringify({
193
+ tool: 'eng_state',
194
+ target: file,
195
+ operation: 'fold the workspace ledger into the engagement projection (window=' + windowSize + ')',
196
+ observed: view,
197
+ confidence: 'observed',
198
+ limitations: [
199
+ '投影是**窗口视图**:每张表只留最新 ' + windowSize + ' 条;dropped=' + view.dropped + '(被逐出)refused=' + view.refused + '(引用不存在的 id 被拒,累计)',
200
+ view.truncated ? '⚠️ truncated=true:被逐出的记录不在这个视图里 —— 完整记录以 eng_report(读全量)为准' : '这次没有截断(每张表都在窗口内)',
201
+ 'ledger 不存在 ⇒ 空 projection(还没开始记录,不是错误)',
202
+ ],
203
+ }, null, 2)
204
+ },
205
+ }))
206
+
207
+ ctx.tools.register(defineTool({
208
+ name: 'eng_report',
209
+ description: 'Render the engagement report as Markdown (goal + authorization, assets, facts with source tools, findings with reproducible steps, counts).',
210
+ parameters: { workspace: workspaceParam },
211
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
212
+ async execute(args, exec) {
213
+ if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_report 需要 workspace(绝对路径)')
214
+ const file = ledgerPath(args.workspace)
215
+ const state = foldEvents((await readText(shell, file, exec)).split('\n'))
216
+ return JSON.stringify({
217
+ tool: 'eng_report',
218
+ target: file,
219
+ operation: 'render the full engagement ledger as Markdown (no window)',
220
+ observed: { markdown: renderReport(args.workspace, state), counts: { facts: state.facts.length, findings: state.findings.length, assets: state.assets.length, edges: state.edges.length }, refused: state.refused },
221
+ confidence: 'observed',
222
+ limitations: [
223
+ '这份报告读的是**全量** ledger(不经窗口)—— 面板上的图是窗口视图,两者条数可能不同',
224
+ 'findings 的复现步骤原样列出(不重新编号内容、不排序)',
225
+ 'ledger 写在 <workspace>/.fly-engagement/events.jsonl;报告可以复制走,记录仍在工作区里',
226
+ ],
227
+ }, null, 2)
228
+ },
229
+ }))
230
+ }