@dsh-fly/dsh-plugin-fly-mode 0.1.4 → 0.1.6

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.
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "dsh-plugin-fly-artifact",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js"
8
+ ],
9
+ "peerDependencies": {
10
+ "@deepseek-ai/cordis": "^4.0.2",
11
+ "@deepseek-ai/dsh-shell": "^0.2.0-rc.2",
12
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2"
13
+ },
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ethanchen669/dsh-fly.git",
18
+ "directory": "packages/dsh-plugin-fly-artifact"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/"
22
+ }
23
+ }
@@ -470,7 +470,7 @@ export function apply(ctx) {
470
470
 
471
471
  ctx.tools.register(defineTool({
472
472
  name: 'bin_readelf',
473
- description: 'Dump ELF headers / program headers / dynamic section (readelf when installed, else parsed natively).',
473
+ description: 'Dump ELF headers / program headers / dynamic section.',
474
474
  parameters: {
475
475
  path: { type: 'string', required: true },
476
476
  mode: { type: 'string', description: 'headers | segments | dynamic' },
@@ -505,7 +505,7 @@ export function apply(ctx) {
505
505
 
506
506
  ctx.tools.register(defineTool({
507
507
  name: 'bin_sections',
508
- description: 'List ELF section headers (objdump -h).',
508
+ description: 'List ELF section headers.',
509
509
  parameters: {
510
510
  path: { type: 'string', required: true },
511
511
  },
@@ -514,7 +514,16 @@ export function apply(ctx) {
514
514
  render: (_args, value) => [{ type: 'text', text: value }],
515
515
  },
516
516
  async execute(args, exec) {
517
- return requireOk(await runCmd(shell, ['objdump', '-h', args.path], exec), 'objdump -h')
517
+ const run = await runCmd(shell, ['objdump', '-h', args.path], exec)
518
+ if (run.exitCode === 0 && run.stdout.trim() !== '') return run.stdout
519
+ // GNU binutils is not part of macOS. Instead of an opaque "command not found",
520
+ // say which method failed and what to use instead (same shape as bin_readelf).
521
+ const kind = await runCmd(shell, ['file', '-b', args.path], exec)
522
+ return [
523
+ `objdump -h is unavailable or failed on this host (exit ${run.exitCode}), so section headers could not be read with it`,
524
+ `file(1) says: ${kind.stdout.trim() || '(nothing)'}`,
525
+ 'GNU binutils does not ship with macOS; for Mach-O use rev_imports (nm -u) or rev_disasm (objdump -d)',
526
+ ].join('\n')
518
527
  },
519
528
  }))
520
529
  }
@@ -7,7 +7,7 @@
7
7
  * · **知识层**:一个 bundled skill(`assets/client-blackbox/`),按需加载。agent 读到的是
8
8
  * 心智模型(三源一闸 S1/S2/S3/G)、六条判据 T1–T6、五阶段带 Gate 流程与可复用脚本模板。
9
9
  * 知识本身不产生副作用,所以不受授权闸门管。
10
- * · **执行层**:六个工具,走宿主 `shell` 接缝。它只管**可观测性与判定**(起代理、判管道、
10
+ * · **执行层**:七个工具,走宿主 `shell` 接缝。它只管**可观测性与判定**(起代理、判管道、
11
11
  * 读抓包、比对前后),改写路径(请求/响应注入)必须显式授权。
12
12
  *
13
13
  * 三条纪律写死在实现里,因为每一条都对应一次实测事故:
@@ -37,6 +37,8 @@ import { createHash } from 'node:crypto'
37
37
  import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
38
38
  import { join } from 'node:path'
39
39
  import { fileURLToPath } from 'node:url'
40
+ // gzip 只用 Node 自带的 `zlib`:`.har.gz` 的识别**按 magic 判**,不新增依赖、也不靠扩展名。
41
+ import { gunzipSync } from 'node:zlib'
40
42
  import { defineTool } from '@deepseek-ai/dsh-tools'
41
43
  import { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill'
42
44
 
@@ -443,6 +445,25 @@ async function statSizeVia(shell, exec, path, what) {
443
445
  return Number(last)
444
446
  }
445
447
 
448
+ /**
449
+ * 读一份 HAR(`bx_har` 的入口):**先 stat 再走接缝读**,与 `bx_flows` 同一条路、同一个量级上限。
450
+ *
451
+ * 顺序不能反:先 stat 才能在读之前就拒掉"重到解不动"的文件(真的读进来才发现超限,那一次
452
+ * base64 已经付过了);而读本身必须经 `readTextFileVia`,否则沙箱拒绝会变成一个裸 EACCES,
453
+ * 而不是 §B4 里那条 `SandboxError`。
454
+ */
455
+ async function readHarFile(shell, exec, path, what) {
456
+ const size = await statSizeVia(shell, exec, path, what)
457
+ if (size > HAR_MAX_BYTES) {
458
+ throw new Error(
459
+ `${what} 有 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${HAR_MAX_BYTES / 1024 / 1024}MB 上限,拒绝读(读它要走 base64,重了解不动)。\n`
460
+ + '修法:导出时只勾目标域名(DevTools 的 filter / mitmproxy 过滤后再存),'
461
+ + '或先用 bx_flows grep 定位目标域名,只抓那一段。',
462
+ )
463
+ }
464
+ return readHarDocument(await readTextFileVia(shell, exec, path, what), path)
465
+ }
466
+
446
467
  /** 状态文件在**工作区内**(唯一允许的自身状态写入面)。 */
447
468
  function statePath(root) {
448
469
  return join(root, '.fly-blackbox', 'proxy.json')
@@ -797,6 +818,329 @@ export function diffJson(a, b, path = '') {
797
818
  return [`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`]
798
819
  }
799
820
 
821
+ // ─────────────────────────────────────────────────────────────────────────────
822
+ // §B7 `bx_har`:HAR 的归一化、摘要与两次捕获对比
823
+ //
824
+ // 两条纪律,各对应一类事故:
825
+ //
826
+ // 1. **白名单式脱敏**(不是"输出前擦一遍"):归一化只**挑**要用的字段,headers / cookies /
827
+ // postData / content 从源头就不复制。HAR 里最常出现的凭据正好在这些地方(`Cookie` /
828
+ // `Authorization` / `Set-Cookie` / 请求体里的令牌),而擦除式脱敏只要漏一个字段,就是
829
+ // 一整份令牌落进模型上下文。URL 的 query 与 fragment 同理:**不入键、不输出**。
830
+ // 2. **截断显式**:任何受 `top` 限界的列表都给 `bounds`(shown / total / omitted),顶层另有
831
+ // `truncated` 布尔 —— 省略绝不藏起来(把"只列了 20 条"读成"一共 20 条"会直接得出错误
832
+ // 的量级结论,这正是 `bx_flows` 那条 truncated 纪律的同族)。
833
+ //
834
+ // 数字口径(同时写进结果的 `limitations`):`bytes` = `response.content.size`(缺失时
835
+ // `response.bodySize`;都没有则 `null`,绝不用 0 假装知道);`timeMs` = `entry.time`。
836
+ // 文件读取走 §B4 的接缝(`base64 -i`,与 `bx_flows` / `bx_diff` 同一条路)。
837
+ // ─────────────────────────────────────────────────────────────────────────────
838
+
839
+ /** `top` 缺省;任何列表超过它就一定在 `bounds.<list>.omitted` 里报出来。 */
840
+ const HAR_DEFAULT_TOP = 20
841
+ const HAR_GROUPINGS = ['host', 'status', 'mime']
842
+ /** 与 `bx_flows` 同一个量级上限:读文件要走 base64(4/3 膨胀),再大就不是"读一份摘要"的重量了。 */
843
+ const HAR_MAX_BYTES = 32 * 1024 * 1024
844
+ /** 对比用的**大小档位**边界:打点/时间戳会让字节数永远不相等,档位才是"变了没有"的判据。 */
845
+ const HAR_SIZE_BUCKETS = [[1024, '<1KB'], [10 * 1024, '1-10KB'], [100 * 1024, '10-100KB'], [1024 * 1024, '100KB-1MB']]
846
+ const HAR_SIZE_BUCKET_TEXT = '0-1KB / 1-10KB / 10-100KB / 100KB-1MB / >=1MB'
847
+
848
+ /** 字节 → 档位。`null`/负数/非数 → `unknown`(缺数据不等于"小")。 */
849
+ export function harSizeBucket(bytes) {
850
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return 'unknown'
851
+ for (const [bound, label] of HAR_SIZE_BUCKETS) if (bytes < bound) return label
852
+ return '>=1MB'
853
+ }
854
+
855
+ /** HAR 的 `request.url` → `{ host, path }`(**query 与 fragment 一律丢掉**:它们常带令牌)。 */
856
+ export function splitHarUrl(raw) {
857
+ const text = typeof raw === 'string' ? raw : ''
858
+ try {
859
+ const url = new URL(text)
860
+ return {
861
+ host: url.host === '' ? '(no host)' : url.host,
862
+ path: url.pathname === '' ? '/' : url.pathname,
863
+ hadQuery: url.search !== '' || url.hash !== '',
864
+ }
865
+ } catch {
866
+ // 相对 URL / 不是 URL:退回正则取可读的那一段,**绝不整串透传**(整串里就带着 query)。
867
+ const match = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)([^?#]*)/iu.exec(text)
868
+ if (match === null) {
869
+ const bare = text.split(/[?#]/u)[0]
870
+ return { host: '(no host)', path: bare === '' ? '(no path)' : bare, hadQuery: /[?#]/u.test(text) }
871
+ }
872
+ return { host: match[1], path: match[2] === '' ? '/' : match[2], hadQuery: /[?#]/u.test(text) }
873
+ }
874
+ }
875
+
876
+ /** `mimeType` 归一:去掉 `; charset=…` 这类参数、转小写(分组要用它当键)。 */
877
+ function harMime(response) {
878
+ const raw = response?.content?.mimeType
879
+ if (typeof raw !== 'string') return '(no mime)'
880
+ const base = raw.split(';')[0].trim().toLowerCase()
881
+ return base === '' ? '(no mime)' : base
882
+ }
883
+
884
+ /** `bytes` 的口径:`response.content.size` → `response.bodySize` → `null`(不猜)。 */
885
+ function harBytes(response) {
886
+ for (const value of [response?.content?.size, response?.bodySize]) {
887
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
888
+ }
889
+ return null
890
+ }
891
+
892
+ function harTimeMs(entry) {
893
+ const value = entry?.time
894
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
895
+ }
896
+
897
+ function harStatus(response) {
898
+ return typeof response?.status === 'number' && Number.isFinite(response.status) ? response.status : null
899
+ }
900
+
901
+ /**
902
+ * 一条 HAR entry → 归一化行。
903
+ *
904
+ * **只有这里列出的字段会出现在输出里**(白名单制):headers / cookies / postData / body
905
+ * 连"被漏出去"的机会都没有 —— 这也是"最强的脱敏"唯一的落地方式:不复制,而不是复制后再擦。
906
+ */
907
+ export function normalizeHarEntry(entry, index) {
908
+ const request = entry?.request ?? {}
909
+ const response = entry?.response ?? {}
910
+ const { host, path, hadQuery } = splitHarUrl(request.url)
911
+ const status = harStatus(response)
912
+ const errored = entry?._error !== undefined && entry?._error !== null
913
+ const method = typeof request.method === 'string' && request.method !== '' ? request.method.toUpperCase() : '(no method)'
914
+ return {
915
+ index,
916
+ method,
917
+ host,
918
+ path,
919
+ hadQuery,
920
+ status,
921
+ timeMs: harTimeMs(entry),
922
+ bytes: harBytes(response),
923
+ mime: harMime(response),
924
+ // status 0 = 请求根本没拿到响应(浏览器取消 / 连接失败),它同样是"错误条目"。
925
+ failed: status === 0 || errored,
926
+ key: `${method} ${host}${path}`,
927
+ }
928
+ }
929
+
930
+ /** 状态码计数:键按数值序(`0` → `200` → `404`),`(no status)` 这类非数值键排在最后。 */
931
+ function harCounts(counts) {
932
+ const keys = [...counts.keys()].sort((left, right) => {
933
+ const a = Number(left)
934
+ const b = Number(right)
935
+ if (Number.isFinite(a) && Number.isFinite(b)) return a - b
936
+ return left.localeCompare(right)
937
+ })
938
+ return Object.fromEntries(keys.map(key => [key, counts.get(key)]))
939
+ }
940
+
941
+ /** 4xx/5xx,外加 status 0(没拿到响应)。 */
942
+ export function isHarError(row) {
943
+ return row.failed === true || (row.status !== null && row.status >= 400)
944
+ }
945
+
946
+ /** 展示一行时的形状(**不含** headers / body / query)。 */
947
+ function harRow(row) {
948
+ return { method: row.method, host: row.host, path: row.path, status: row.status, timeMs: row.timeMs, bytes: row.bytes, mime: row.mime }
949
+ }
950
+
951
+ /**
952
+ * 文件字节 → `{ file, har, entries }`。
953
+ *
954
+ * gzip **按 magic 判**(`1f 8b`)而不是扩展名:反过来(信扩展名)会让一份没压缩的 `.har.gz`
955
+ * 走进解压分支、报一个看不懂的错;BOM 在 `JSON.parse` 前剔除(有些导出器会带)。
956
+ */
957
+ export function readHarDocument(buffer, path) {
958
+ const gzip = buffer.length > 1 && buffer[0] === 0x1f && buffer[1] === 0x8b
959
+ let payload = buffer
960
+ if (gzip) {
961
+ try {
962
+ payload = gunzipSync(buffer)
963
+ } catch (error) {
964
+ throw new Error(`${path} 带 gzip magic(1f 8b)却解压失败:${error.message} —— 文件可能被截断,或根本不是 gzip。`)
965
+ }
966
+ }
967
+ let parsed
968
+ try {
969
+ parsed = JSON.parse(payload.toString('utf8').replace(/^\uFEFF/u, ''))
970
+ } catch (error) {
971
+ throw new Error(`${path} 不是合法 JSON${gzip ? '(已按 gzip magic 解压)' : ''}:${error.message}`)
972
+ }
973
+ const entries = parsed?.log?.entries
974
+ if (!Array.isArray(entries)) {
975
+ throw new Error(`${path} 不像 HAR:log.entries 不是数组(HAR 1.2 的条目都在 log.entries 下)。`)
976
+ }
977
+ const creator = parsed.log.creator
978
+ return {
979
+ file: {
980
+ path,
981
+ bytes: buffer.length,
982
+ ...(gzip ? { uncompressedBytes: payload.length } : {}),
983
+ gzip,
984
+ sha256: createHash('sha256').update(buffer).digest('hex'),
985
+ },
986
+ har: {
987
+ version: typeof parsed.log.version === 'string' ? parsed.log.version : null,
988
+ creator: typeof creator?.name === 'string'
989
+ ? { name: creator.name, version: typeof creator.version === 'string' ? creator.version : null }
990
+ : null,
991
+ },
992
+ entries: entries.map((entry, index) => normalizeHarEntry(entry, index)),
993
+ }
994
+ }
995
+
996
+ /**
997
+ * 摘要:分组聚合(条数 / 字节 / 状态码分布)+ 最慢 N + 错误条目。
998
+ *
999
+ * 返回的是**未截断**的全量列表 —— 截断由调用方按 `top` 做,并且必须把省略了几个报出来。
1000
+ * 排序一律是**确定**的(条数 → 字节 → 键名 / 耗时 → 键名):同一份 HAR 两次跑出不同的
1001
+ * "最热分组",对比就没有意义了。
1002
+ */
1003
+ export function summarizeHarEntries(entries, groupBy = 'host') {
1004
+ const grouped = new Map()
1005
+ for (const row of entries) {
1006
+ const key = groupBy === 'status'
1007
+ ? (row.status === null ? '(no status)' : String(row.status))
1008
+ : groupBy === 'mime' ? row.mime : row.host
1009
+ const group = grouped.get(key) ?? { key, entries: 0, bytes: 0, bytesUnknown: 0, statuses: new Map() }
1010
+ group.entries += 1
1011
+ if (row.bytes === null) group.bytesUnknown += 1
1012
+ else group.bytes += row.bytes
1013
+ const status = row.status === null ? '(no status)' : String(row.status)
1014
+ group.statuses.set(status, (group.statuses.get(status) ?? 0) + 1)
1015
+ grouped.set(key, group)
1016
+ }
1017
+ const groups = [...grouped.values()]
1018
+ .map(group => ({
1019
+ key: group.key,
1020
+ entries: group.entries,
1021
+ bytes: group.bytes,
1022
+ bytesUnknown: group.bytesUnknown,
1023
+ statuses: harCounts(group.statuses),
1024
+ }))
1025
+ .sort((left, right) => right.entries - left.entries || right.bytes - left.bytes || left.key.localeCompare(right.key))
1026
+ const slowest = entries
1027
+ .filter(row => row.timeMs !== null)
1028
+ .sort((left, right) => right.timeMs - left.timeMs || left.key.localeCompare(right.key))
1029
+ const errors = entries
1030
+ .filter(isHarError)
1031
+ .sort((left, right) => (right.status ?? -1) - (left.status ?? -1) || (right.timeMs ?? -1) - (left.timeMs ?? -1) || left.key.localeCompare(right.key))
1032
+ return { groups, slowest, errors, withoutTime: entries.length - slowest.length }
1033
+ }
1034
+
1035
+ /** 同一个键下的多条请求折叠成一份可比对的小结。 */
1036
+ function harAggregate(rows) {
1037
+ const statuses = new Map()
1038
+ let bytes = 0
1039
+ let bytesUnknown = 0
1040
+ let maxTimeMs = null
1041
+ for (const row of rows) {
1042
+ const status = row.status === null ? '(no status)' : String(row.status)
1043
+ statuses.set(status, (statuses.get(status) ?? 0) + 1)
1044
+ if (row.bytes === null) bytesUnknown += 1
1045
+ else bytes += row.bytes
1046
+ if (row.timeMs !== null) maxTimeMs = maxTimeMs === null ? row.timeMs : Math.max(maxTimeMs, row.timeMs)
1047
+ }
1048
+ return {
1049
+ entries: rows.length,
1050
+ statuses: harCounts(statuses),
1051
+ bytes,
1052
+ bytesUnknown,
1053
+ // 字节一个都没读到时不编档位:`unknown` 才是诚实的答案。
1054
+ bucket: harSizeBucket(bytesUnknown === rows.length ? null : bytes),
1055
+ maxTimeMs,
1056
+ }
1057
+ }
1058
+
1059
+ function harIndex(entries) {
1060
+ const index = new Map()
1061
+ for (const row of entries) {
1062
+ const rows = index.get(row.key) ?? []
1063
+ rows.push(row)
1064
+ index.set(row.key, rows)
1065
+ }
1066
+ return index
1067
+ }
1068
+
1069
+ const harCompareRow = rows => ({
1070
+ key: rows[0].key,
1071
+ method: rows[0].method,
1072
+ host: rows[0].host,
1073
+ path: rows[0].path,
1074
+ ...harAggregate(rows),
1075
+ })
1076
+
1077
+ /**
1078
+ * 两次捕获的对比,键是 `method + host + path`。
1079
+ *
1080
+ * `changed` 只认两件事:**状态码分布**变了,或大小**档位**变了(逐字节比会天天报差异,
1081
+ * 档位才是"变了没有"的判据)。同一个键出现多次时按该键的全部条目折叠后再比 —— 否则
1082
+ * "第一次 200、重试 500"这种最常见的形状会被压成一条而看不出来。
1083
+ */
1084
+ export function compareHarEntries(baseEntries, otherEntries) {
1085
+ const base = harIndex(baseEntries)
1086
+ const other = harIndex(otherEntries)
1087
+ const added = []
1088
+ const removed = []
1089
+ const changed = []
1090
+ let unchanged = 0
1091
+ for (const [key, rows] of other) {
1092
+ if (!base.has(key)) added.push(harCompareRow(rows))
1093
+ }
1094
+ for (const [key, rows] of base) {
1095
+ if (!other.has(key)) {
1096
+ removed.push(harCompareRow(rows))
1097
+ continue
1098
+ }
1099
+ const before = harAggregate(rows)
1100
+ const after = harAggregate(other.get(key))
1101
+ const why = []
1102
+ if (JSON.stringify(before.statuses) !== JSON.stringify(after.statuses)) why.push('status')
1103
+ if (before.bucket !== after.bucket) why.push('size')
1104
+ if (why.length === 0) {
1105
+ unchanged += 1
1106
+ continue
1107
+ }
1108
+ changed.push({ key, method: rows[0].method, host: rows[0].host, path: rows[0].path, why, before, after })
1109
+ }
1110
+ const byKey = (left, right) => left.key.localeCompare(right.key)
1111
+ return {
1112
+ added: added.sort(byKey),
1113
+ removed: removed.sort(byKey),
1114
+ changed: changed.sort(byKey),
1115
+ unchanged,
1116
+ counts: { added: added.length, removed: removed.length, changed: changed.length, unchanged },
1117
+ }
1118
+ }
1119
+
1120
+ /** 按 `top` 切一刀,并把"切掉了几个"一起返回(省略绝不藏起来)。 */
1121
+ function harBound(rows, top) {
1122
+ const shown = rows.slice(0, top)
1123
+ return { rows: shown, bounds: { shown: shown.length, total: rows.length, omitted: rows.length - shown.length } }
1124
+ }
1125
+
1126
+ /**
1127
+ * 这份摘要**看不见什么** —— 照 §5 的最小证据形状,逐条写给模型(也写给以后改这个工具的人:
1128
+ * 每加一个数字字段,都要在这里回答"它的口径是什么、缺数据时是什么样")。
1129
+ */
1130
+ function harLimitations({ withQuery, withoutTime, compare, truncated, top }) {
1131
+ const lines = [
1132
+ '离线读文件:不发起任何网络请求、不改本机状态。',
1133
+ '未分析 headers 与 body:输出里不含任何请求/响应头、Cookie、postData 或 body 内容(白名单式脱敏,不是输出前擦除)。',
1134
+ `URL 的 query 与 fragment 不入键、不输出(本次 ${withQuery} 条 URL 带 query/fragment,已剔除)。`,
1135
+ 'bytes 取 response.content.size(缺失时 response.bodySize),只算响应体;缺失记 null 并计入 bytesUnknown,不当 0。',
1136
+ `耗时取 HAR 里记录的 entry.time(毫秒),不是本次重测;${withoutTime} 条没有该字段,不参与最慢排名。`,
1137
+ 'errors = 4xx/5xx,外加 status 0(请求没拿到响应,例如被取消或连接失败)。',
1138
+ ]
1139
+ if (compare) lines.push(`对比键是 method + host + path,大小档位按同一键的响应体合计分桶(${HAR_SIZE_BUCKET_TEXT})。`)
1140
+ if (truncated) lines.push(`输出被 top=${top} 截断:看每一段的 bounds(shown/total/omitted),这不是全量。`)
1141
+ return lines
1142
+ }
1143
+
800
1144
  // ─────────────────────────────────────────────────────────────────────────────
801
1145
  // §B6 授权闸门
802
1146
  // ─────────────────────────────────────────────────────────────────────────────
@@ -824,6 +1168,8 @@ const GATE_TABLE = {
824
1168
  bx_ca: { readOnly: ['status', 'command', 'verify'] },
825
1169
  // 只读:离线读 mitmproxy dump。
826
1170
  bx_flows: { readOnly: ['hosts', 'list', 'show', 'grep'] },
1171
+ // 只读:离线读 HAR(摘要 / 两份对比),没有 action 参数。
1172
+ bx_har: { actionless: true },
827
1173
  // 没有 action 参数,只读两份 JSON。
828
1174
  bx_diff: { actionless: true },
829
1175
  }
@@ -832,7 +1178,7 @@ function gateReason(tool, action) {
832
1178
  const known = Object.hasOwn(GATE_TABLE, tool)
833
1179
  return `fly-blackbox: \`${tool}\` 的 \`${action ?? ''}\` 会改变本机状态(起代理 / 主动探测 / 动系统代理),`
834
1180
  + '属于**改动路径**,必须显式授权:设 FLY_ALLOW_INTERCEPT=1 再调用。'
835
- + '只读路径(bx_proxy status、bx_sysproxy status、bx_ca *、bx_flows *、bx_diff)不受这道门管。'
1181
+ + '只读路径(bx_proxy status、bx_sysproxy status、bx_ca *、bx_flows *、bx_har、bx_diff)不受这道门管。'
836
1182
  + (known ? '' : `(\`${tool}\` 没有在授权表 GATE_TABLE 里登记,按 fail-closed 处理:登记它之前一律受控。)`)
837
1183
  }
838
1184
 
@@ -1336,7 +1682,7 @@ export function apply(ctx) {
1336
1682
  // ── bx_flows ─────────────────────────────────────────────────────────────
1337
1683
  ctx.tools.register(defineTool({
1338
1684
  name: 'bx_flows',
1339
- description: 'Read a mitmproxy dump offline (hosts | list | show | grep). Reads through the shell seam, refuses truncated output, and gates on the flow format version.',
1685
+ description: 'Read a mitmproxy dump offline (hosts | list | show | grep).',
1340
1686
  parameters: {
1341
1687
  action: { type: 'string', required: true, description: 'hosts | list | show | grep' },
1342
1688
  path: { type: 'string', description: 'dump path, default <workspace>/.fly-blackbox/flows.mitm' },
@@ -1415,6 +1761,86 @@ export function apply(ctx) {
1415
1761
  },
1416
1762
  }))
1417
1763
 
1764
+ // ── bx_har ───────────────────────────────────────────────────────────────
1765
+ ctx.tools.register(defineTool({
1766
+ name: 'bx_har',
1767
+ description: 'Summarize a HAR capture offline (entry counts, groups, slowest entries, 4xx/5xx); optionally diff two captures by method+host+path. Never includes headers or bodies.',
1768
+ parameters: {
1769
+ path: { type: 'string', required: true, description: 'HAR file path (.har or .har.gz; gzip is detected by its magic bytes)' },
1770
+ compareWith: { type: 'string', description: 'second HAR file; adds an added/removed/changed diff against path' },
1771
+ groupBy: { type: 'string', description: 'host | status | mime, default host' },
1772
+ top: { type: 'number', description: 'max rows per list, default 20; anything omitted is counted under bounds' },
1773
+ },
1774
+ output: stringOutput,
1775
+ async execute(args, exec) {
1776
+ const path = typeof args.path === 'string' ? args.path.trim() : ''
1777
+ if (path === '') throw new Error('bx_har 需要 path(.har 或 .har.gz 的路径)')
1778
+ const groupBy = args.groupBy ?? 'host'
1779
+ if (!HAR_GROUPINGS.includes(groupBy)) {
1780
+ throw new Error(`bx_har 的 groupBy 只能是 ${HAR_GROUPINGS.join(' / ')}:给了 ${JSON.stringify(args.groupBy)}`)
1781
+ }
1782
+ const top = args.top === undefined ? HAR_DEFAULT_TOP : args.top
1783
+ if (typeof top !== 'number' || !Number.isInteger(top) || top < 1) {
1784
+ throw new Error(`bx_har 的 top 必须是 ≥1 的整数:给了 ${JSON.stringify(args.top)}`)
1785
+ }
1786
+
1787
+ const base = await readHarFile(shell, exec, path, 'HAR')
1788
+ const summary = summarizeHarEntries(base.entries, groupBy)
1789
+ const groups = harBound(summary.groups, top)
1790
+ const slowest = harBound(summary.slowest, top)
1791
+ const errors = harBound(summary.errors, top)
1792
+
1793
+ const result = {
1794
+ file: base.file,
1795
+ har: base.har,
1796
+ total: base.entries.length,
1797
+ groupBy,
1798
+ groups: groups.rows,
1799
+ slowest: slowest.rows.map(harRow),
1800
+ errors: errors.rows.map(row => ({ ...harRow(row), failed: row.failed })),
1801
+ top,
1802
+ bounds: {
1803
+ groups: groups.bounds,
1804
+ slowest: slowest.bounds,
1805
+ errors: errors.bounds,
1806
+ },
1807
+ }
1808
+
1809
+ const comparePath = typeof args.compareWith === 'string' ? args.compareWith.trim() : ''
1810
+ if (comparePath !== '') {
1811
+ const other = await readHarFile(shell, exec, comparePath, '对比 HAR')
1812
+ const compared = compareHarEntries(base.entries, other.entries)
1813
+ const added = harBound(compared.added, top)
1814
+ const removed = harBound(compared.removed, top)
1815
+ const changed = harBound(compared.changed, top)
1816
+ result.compare = {
1817
+ base: base.file,
1818
+ other: other.file,
1819
+ total: { base: base.entries.length, other: other.entries.length },
1820
+ counts: compared.counts,
1821
+ added: added.rows,
1822
+ removed: removed.rows,
1823
+ changed: changed.rows,
1824
+ bounds: { added: added.bounds, removed: removed.bounds, changed: changed.bounds },
1825
+ }
1826
+ }
1827
+
1828
+ const omitted = [
1829
+ ...Object.values(result.bounds),
1830
+ ...(result.compare === undefined ? [] : Object.values(result.compare.bounds)),
1831
+ ].reduce((total, bound) => total + bound.omitted, 0)
1832
+ result.truncated = omitted > 0
1833
+ result.limitations = harLimitations({
1834
+ withQuery: base.entries.filter(row => row.hadQuery).length,
1835
+ withoutTime: summary.withoutTime,
1836
+ compare: result.compare !== undefined,
1837
+ truncated: result.truncated,
1838
+ top,
1839
+ })
1840
+ return JSON.stringify(result, null, 2)
1841
+ },
1842
+ }))
1843
+
1418
1844
  // ── bx_diff ──────────────────────────────────────────────────────────────
1419
1845
  ctx.tools.register(defineTool({
1420
1846
  name: 'bx_diff',
@@ -430,7 +430,7 @@ export function apply(ctx) {
430
430
 
431
431
  ctx.tools.register(defineTool({
432
432
  name: 'diag_net',
433
- description: 'Snapshot listening sockets (lsof/netstat best-effort).',
433
+ description: 'Snapshot listening sockets.',
434
434
  parameters: {},
435
435
  output: {
436
436
  schema: { type: 'string' },
@@ -118,7 +118,7 @@ export function apply(ctx) {
118
118
 
119
119
  ctx.tools.register(defineTool({
120
120
  name: 'recon_ports',
121
- description: 'TCP connect scan of ports on a lab target: nmap when installed, else the system nc (authorized use only).',
121
+ description: 'TCP connect scan of ports on a lab target (authorized use only).',
122
122
  parameters: {
123
123
  host: { type: 'string', required: true },
124
124
  ports: { type: 'string', description: 'Comma list with optional ranges, default top common' },
@@ -94,7 +94,7 @@ export function apply(ctx) {
94
94
 
95
95
  ctx.tools.register(defineTool({
96
96
  name: 'rev_imports',
97
- description: 'List imported (undefined) symbols: objdump -T for ELF, nm -u for Mach-O and stripped files.',
97
+ description: 'List imported (undefined) symbols.',
98
98
  parameters: {
99
99
  path: { type: 'string', required: true },
100
100
  },
@@ -155,7 +155,7 @@ export function apply(ctx) {
155
155
 
156
156
  ctx.tools.register(defineTool({
157
157
  name: 'rev_gadget_scan',
158
- description: 'Scan for simple ROP gadgets with ROPgadget if installed, else a built-in objdump scanner.',
158
+ description: 'Scan for simple ROP gadgets.',
159
159
  parameters: {
160
160
  path: { type: 'string', required: true },
161
161
  filter: { type: 'string', description: 'Optional gadget filter, e.g. "pop rdi"' },
@@ -0,0 +1,47 @@
1
+ # dsh-plugin-fly-review —— Fly 评审姿态
2
+
3
+ `/review` 开关一个**每会话的评审姿态**:只读地看、**先讲阻塞问题**、每条结论都带证据
4
+ (`path:line` / 命令输出 / 复现步骤)、不夸奖也不复述;看完用 `exit_review_mode` 把判词**交用户裁决**。
5
+
6
+ 它是 **agent 平面插件** —— 由 preset 的**行表**挂载(像 `@deepseek-ai/dsh-plan-mode` 那样),
7
+ 不是 profile 层(没有 `dsh.bundle`)。形状照上游 plan 模式实现,名字与文案全归本包:
8
+
9
+ | 机制 | 说明 |
10
+ | --- | --- |
11
+ | 姿态的效果 | **一段引导**(`review:policy` 段)—— 进/出姿态只改提示词段,**不改工具目录**(收口工具常驻注册) |
12
+ | 状态 | 记在**会话日志**(`review/mode`),由会话投影折叠 ⇒ **resume / fork 自动恢复** |
13
+ | 切态时机 | **下一个被接受的 turn 边界**(`agent/pre-step`)⇒ 同一步里前后一致 |
14
+ | 命令 | `/review` 开 · `/review off` 关 · `/review <文字>` 附带一句焦点(文字会作为用户消息交给模型) |
15
+ | 收口 | `exit_review_mode(review)` 弹裁决卡(`接受评审并退出` / `继续评审`),判词正文进卡片 detail |
16
+
17
+ ## ⚠️ 只读是**引导**,不是强制
18
+
19
+ 姿态**不限制权限** —— 这正是上游 plan 模式的口径(它的注释:sandbox mode 与 approval policy
20
+ *独立*执行,不读也不写模式状态)。要**硬**只读,请配合权限/沙箱策略,或者用一个**不含写工具行**的 preset。
21
+
22
+ 本包不在文案里把引导说成强制;命令回执里也明确写着这一点。
23
+
24
+ ## 配置(行配置)
25
+
26
+ ```yaml
27
+ - id: fly-review
28
+ name: 'dsh-plugin-fly-review'
29
+ config:
30
+ section: |
31
+ …你的纪律文本(整段覆写内置那份)…
32
+ ```
33
+
34
+ · `section` 可省(用内置纪律文本)· **未知键在加载期直接抛**(配置写错要大声失败,不能被忽略)
35
+
36
+ ## 纪律文本的来源
37
+
38
+ 内置文本取自 `dsh-plugin-fly-smart` 的 `consultPrompt`
39
+ (`tools/index.js:352-365`,2026-10-13 版),改写成"模式"口吻。用户裁决"不动 smart 自己"
40
+ ⇒ 两份文本**共存**:本包一份、smart 一份。要收敛时走"抽库 + 两个消费者构建期内联"
41
+ (同 `@dsh-fly/fly-panel-style` 那次的姿势),不做跨仓钉死。
42
+
43
+ ## 测试
44
+
45
+ ```bash
46
+ node --test tests/ # 桩按真接缝形状喂:投影读写、命令注册、段注入、收口工具
47
+ ```
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "dsh-plugin-fly-review",
3
+ "version": "0.1.0",
4
+ "description": "Fly 评审姿态(agent 平面插件):/review 开关一个只读评审姿态,引导模型先讲阻塞问题、给 path:line 证据,判词由用户裁决。照 @deepseek-ai/dsh-plan-mode 的形状实现。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./tools/index.js",
8
+ "exports": {
9
+ ".": "./tools/index.js",
10
+ "./tools/index.js": "./tools/index.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "tools",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test tests/*.spec.mjs"
19
+ },
20
+ "devDependencies": {
21
+ "@deepseek-ai/cordis": "^4.0.4",
22
+ "@deepseek-ai/dsh-brand": "^0.2.0-rc.2",
23
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
24
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
25
+ "@deepseek-ai/dsh-user-questions": "^0.2.0-rc.2",
26
+ "zod": "^3.24.0"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/ethanchen669/dsh-fly.git",
31
+ "directory": "packages/dsh-plugin-fly-review"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org/"
36
+ }
37
+ }