@dsh-fly/dsh-plugin-fly-mode 0.0.0-stage → 0.1.2
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 +21 -0
- package/README.md +51 -2
- package/cordis.patch.yml +399 -0
- package/package.json +32 -4
- package/tools/dsh-plugin-fly-binary/index.js +520 -0
- package/tools/dsh-plugin-fly-binary/package.json +23 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +163 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/mitm_addon_template.py +302 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/rules.example.json +52 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/case-ttw.md +157 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/checklist.md +133 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/playbook.md +228 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/rules.schema.md +76 -0
- package/tools/dsh-plugin-fly-blackbox/index.js +1453 -0
- package/tools/dsh-plugin-fly-blackbox/package.json +31 -0
- package/tools/dsh-plugin-fly-diag/index.js +772 -0
- package/tools/dsh-plugin-fly-diag/package.json +24 -0
- package/tools/dsh-plugin-fly-diag/wifi.js +402 -0
- package/tools/dsh-plugin-fly-guard/index.js +101 -0
- package/tools/dsh-plugin-fly-guard/package.json +26 -0
- package/tools/dsh-plugin-fly-pwn/index.js +583 -0
- package/tools/dsh-plugin-fly-pwn/package.json +23 -0
- package/tools/dsh-plugin-fly-recon/index.js +184 -0
- package/tools/dsh-plugin-fly-recon/package.json +23 -0
- package/tools/dsh-plugin-fly-reverse/index.js +178 -0
- package/tools/dsh-plugin-fly-reverse/package.json +23 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-fly-diag",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"wifi.js"
|
|
9
|
+
],
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
12
|
+
"@deepseek-ai/dsh-shell": "^0.2.0-rc.2",
|
|
13
|
+
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ethanchen669/dsh-fly.git",
|
|
19
|
+
"directory": "packages/dsh-plugin-fly-diag"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"registry": "https://registry.npmjs.org/"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-layer report parsing for the `fly-diag` tool face.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only — no I/O, no `ctx`, no clock, no `system_profiler` call.
|
|
5
|
+
* The collector commands live in `index.js`; this module turns their stdout into
|
|
6
|
+
* numbers. The split exists for one reason: the tool face is only as honest as
|
|
7
|
+
* this file, and this file can be pinned against captured fixtures
|
|
8
|
+
* (`scripts/tests/wifi-report.spec.mjs`) instead of against whatever radio the
|
|
9
|
+
* host running the gate happens to have.
|
|
10
|
+
*
|
|
11
|
+
* ## The `0 dBm` trap — why signal parsing is stricter than it looks
|
|
12
|
+
*
|
|
13
|
+
* A first revision of this logic lived in a shell script and treated any value
|
|
14
|
+
* that was not a literal `-` as a number:
|
|
15
|
+
*
|
|
16
|
+
* s = ($4 == "-") ? -999 : $4 + 0
|
|
17
|
+
*
|
|
18
|
+
* Two shapes reach that expression as `0`:
|
|
19
|
+
*
|
|
20
|
+
* · `system_profiler` prints **no `Signal / Noise` line at all** for many
|
|
21
|
+
* entries. In the captured fixture below, 15 of 16 neighbour entries have no
|
|
22
|
+
* such line.
|
|
23
|
+
* · When it cannot measure, it can print `0 dBm / 0 dBm`.
|
|
24
|
+
*
|
|
25
|
+
* Either way the channel map rendered `ch36 80MHz 0 dBm` — the strongest
|
|
26
|
+
* possible reading, invented out of the *absence* of a measurement. So
|
|
27
|
+
* `parseSignalDbm()` returns `null` for empty, `-`, non-numeric text, **and for
|
|
28
|
+
* `0`**: 0 dBm is 1 mW at the antenna, no consumer AP ever measures it, and
|
|
29
|
+
* macOS uses it as the "no reading" placeholder. `0` is a sentinel here, not a
|
|
30
|
+
* measurement. Every consumer must treat `null` as "unknown" and never as 0.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-plugin-fly-diag/wifi
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The one "we have no measurement" value. Deliberately `null`, never `0`. */
|
|
36
|
+
const UNKNOWN = null
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse one half of a `Signal / Noise: -58 dBm / -94 dBm` line.
|
|
40
|
+
*
|
|
41
|
+
* @param raw - field text such as `-58 dBm`, `0 dBm`, `-`, or `''`.
|
|
42
|
+
* @returns dBm as a number, or `null` when there is no real measurement.
|
|
43
|
+
*/
|
|
44
|
+
export function parseSignalDbm(raw) {
|
|
45
|
+
const text = String(raw ?? '').trim()
|
|
46
|
+
if (text === '' || text === '-') return UNKNOWN
|
|
47
|
+
const match = /^(-?\d+(?:\.\d+)?)\s*dBm$/u.exec(text)
|
|
48
|
+
if (match === null) return UNKNOWN
|
|
49
|
+
const value = Number(match[1])
|
|
50
|
+
if (!Number.isFinite(value) || value === 0) return UNKNOWN
|
|
51
|
+
return value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse `Channel: 149 (5GHz, 80MHz)`.
|
|
56
|
+
*
|
|
57
|
+
* The number is the **primary** 20MHz channel, not the block centre: a radio
|
|
58
|
+
* reporting `149 (5GHz, 80MHz)` occupies 149–161, and one reporting
|
|
59
|
+
* `40 (5GHz, 80MHz)` occupies the *same* 149-free block 36–48. Reading these as
|
|
60
|
+
* three different "channels" is how a channel map ends up claiming four
|
|
61
|
+
* neighbours where there is really one block.
|
|
62
|
+
*
|
|
63
|
+
* @param raw - the field text after `Channel:`.
|
|
64
|
+
* @returns `{ channel, band, widthMhz }`, or `null` when unparseable.
|
|
65
|
+
*/
|
|
66
|
+
export function parseChannelLine(raw) {
|
|
67
|
+
const match = /^(\d+)\s*\(([^,]+),\s*(\d+)\s*MHz\)$/u.exec(String(raw ?? '').trim())
|
|
68
|
+
if (match === null) return null
|
|
69
|
+
return { channel: Number(match[1]), band: match[2].trim(), widthMhz: Number(match[3]) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse `Supported Channels: 1 (2GHz), 36 (5GHz), …`.
|
|
74
|
+
*
|
|
75
|
+
* This list is a regulatory answer that macOS does **not** redact, so the
|
|
76
|
+
* feasible-channel arithmetic below is derived from the host's own report
|
|
77
|
+
* instead of from a hardcoded country table.
|
|
78
|
+
*
|
|
79
|
+
* @param raw - the field text after `Supported Channels:`.
|
|
80
|
+
* @returns array of `{ channel, band }`, in report order.
|
|
81
|
+
*/
|
|
82
|
+
export function parseSupportedChannels(raw) {
|
|
83
|
+
return [...String(raw ?? '').matchAll(/(\d+)\s*\(([^)]+)\)/gu)]
|
|
84
|
+
.map(match => ({ channel: Number(match[1]), band: match[2].trim() }))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalize `802.11 a/b/g/n/ac/ax` into full mode names.
|
|
89
|
+
*
|
|
90
|
+
* macOS puts the family prefix on the first token only, so a naive
|
|
91
|
+
* `split('/')` yields `['802.11 a', 'b', 'g', 'n', 'ac', 'ax']` and every
|
|
92
|
+
* `includes('802.11ax')` test — and every capability claim built on it — fails
|
|
93
|
+
* while looking like the host simply does not support the mode.
|
|
94
|
+
*
|
|
95
|
+
* @param raw - the field text after `Supported PHY Modes:`.
|
|
96
|
+
* @returns mode names such as `['802.11a', '802.11b', '802.11ax']`.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizePhyModes(raw) {
|
|
99
|
+
const parts = String(raw ?? '').split('/').map(part => part.trim()).filter(Boolean)
|
|
100
|
+
if (parts.length === 0) return []
|
|
101
|
+
const head = /^(802\.11\s*)([A-Za-z]+)$/u.exec(parts[0])
|
|
102
|
+
if (head === null) return parts
|
|
103
|
+
const family = head[1].replace(/\s+/gu, '')
|
|
104
|
+
return [`${family}${head[2]}`, ...parts.slice(1).map(part => `${family}${part}`)]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `Key: value` lines inside a network entry or an interface block.
|
|
109
|
+
*
|
|
110
|
+
* The key class **must** allow `/`: the field that carries the signal is spelled
|
|
111
|
+
* `Signal / Noise`, and a key class of `[A-Za-z ]` silently fails to match it —
|
|
112
|
+
* which yields `rssiDbm: null` for every entry in the report. That failure is
|
|
113
|
+
* indistinguishable from "this host has no measurements", i.e. it renders as a
|
|
114
|
+
* confidently empty report. Only a fixture asserting the concrete -58 / -94
|
|
115
|
+
* values catches it (see `scripts/tests/wifi-report.spec.mjs`).
|
|
116
|
+
*/
|
|
117
|
+
const FIELD = /^\s*([A-Za-z][A-Za-z /]*?):\s*(.*)$/u
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse one network entry (the indented lines under a `<redacted>:` heading).
|
|
121
|
+
*
|
|
122
|
+
* Every absent field becomes `null`. A missing `Transmit Rate` must never
|
|
123
|
+
* surface as `0`, for the same reason a missing signal must not: the renderer
|
|
124
|
+
* cannot tell the difference after the fact.
|
|
125
|
+
*
|
|
126
|
+
* @param lines - the entry's raw lines, headed by no `<redacted>:` line itself.
|
|
127
|
+
* @returns the normalized entry record.
|
|
128
|
+
*/
|
|
129
|
+
export function parseNetworkEntry(lines) {
|
|
130
|
+
const fields = new Map()
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const match = FIELD.exec(line)
|
|
133
|
+
if (match !== null) fields.set(match[1].trim(), match[2].trim())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const channel = parseChannelLine(fields.get('Channel'))
|
|
137
|
+
const signal = fields.get('Signal / Noise')
|
|
138
|
+
const halves = signal === undefined ? [] : signal.split('/').map(part => part.trim())
|
|
139
|
+
// A beacon with no signal line is the common case, not the exception: index 0
|
|
140
|
+
// and 1 are both absent rather than defaulted, so `snrDb` stays null too.
|
|
141
|
+
const rssi = parseSignalDbm(halves[0])
|
|
142
|
+
const noise = parseSignalDbm(halves[1])
|
|
143
|
+
const rate = Number(fields.get('Transmit Rate'))
|
|
144
|
+
const mcs = Number(fields.get('MCS Index'))
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
phy: fields.get('PHY Mode') ?? null,
|
|
148
|
+
channel: channel?.channel ?? null,
|
|
149
|
+
band: channel?.band ?? null,
|
|
150
|
+
widthMhz: channel?.widthMhz ?? null,
|
|
151
|
+
security: fields.get('Security') ?? null,
|
|
152
|
+
rssiDbm: rssi,
|
|
153
|
+
noiseDbm: noise,
|
|
154
|
+
snrDb: rssi === null || noise === null ? null : rssi - noise,
|
|
155
|
+
transmitRateMbps: Number.isFinite(rate) && rate > 0 ? rate : null,
|
|
156
|
+
mcs: Number.isFinite(mcs) ? mcs : null,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse a whole `system_profiler SPAirPortDataType` report.
|
|
162
|
+
*
|
|
163
|
+
* Structure it walks: `Interfaces:` → one `<ifname>:` block per interface →
|
|
164
|
+
* `Current Network Information:` and `Other Local Wi-Fi Networks:` → one
|
|
165
|
+
* `<redacted>:` entry each. Indentation is **not** trusted: the real fixture
|
|
166
|
+
* contains a driver banner at column 0 in the middle of the `en0` block, so the
|
|
167
|
+
* walker keys off the section headings and the numeric interface names instead
|
|
168
|
+
* of off indentation depth.
|
|
169
|
+
*
|
|
170
|
+
* @param text - raw stdout of `system_profiler SPAirPortDataType`.
|
|
171
|
+
* @returns `{ interfaces }`, each with `current` (or null) and `neighbors`.
|
|
172
|
+
*/
|
|
173
|
+
export function parseWifiReport(text) {
|
|
174
|
+
const interfaces = []
|
|
175
|
+
let iface = null
|
|
176
|
+
let section = null
|
|
177
|
+
let entry = null
|
|
178
|
+
|
|
179
|
+
const closeEntry = () => {
|
|
180
|
+
if (entry === null) return
|
|
181
|
+
const parsed = parseNetworkEntry(entry)
|
|
182
|
+
if (section === 'neighbors') iface.neighbors.push(parsed)
|
|
183
|
+
else if (section === 'current') iface.current = parsed
|
|
184
|
+
entry = null
|
|
185
|
+
}
|
|
186
|
+
const closeInterface = () => {
|
|
187
|
+
closeEntry()
|
|
188
|
+
if (iface !== null) interfaces.push(iface)
|
|
189
|
+
iface = null
|
|
190
|
+
section = null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
194
|
+
if (/^\s*Current Network Information:\s*$/u.test(line)) {
|
|
195
|
+
closeEntry()
|
|
196
|
+
section = 'current'
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
if (/^\s*Other Local Wi-Fi Networks:\s*$/u.test(line)) {
|
|
200
|
+
closeEntry()
|
|
201
|
+
section = 'neighbors'
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
// ` en0:` / ` awdl0:` — a bare interface heading.
|
|
205
|
+
const heading = /^\s{4,10}([A-Za-z]+\d+):\s*$/u.exec(line)
|
|
206
|
+
if (heading !== null) {
|
|
207
|
+
closeInterface()
|
|
208
|
+
iface = {
|
|
209
|
+
name: heading[1],
|
|
210
|
+
status: null,
|
|
211
|
+
countryCode: null,
|
|
212
|
+
phyModes: [],
|
|
213
|
+
supportedChannels: [],
|
|
214
|
+
current: null,
|
|
215
|
+
neighbors: [],
|
|
216
|
+
}
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (iface === null) continue
|
|
220
|
+
|
|
221
|
+
if (section === null) {
|
|
222
|
+
const field = FIELD.exec(line)
|
|
223
|
+
if (field === null) continue
|
|
224
|
+
const key = field[1].trim()
|
|
225
|
+
const value = field[2].trim()
|
|
226
|
+
if (key === 'Status') iface.status = value
|
|
227
|
+
else if (key === 'Country Code') iface.countryCode = value
|
|
228
|
+
else if (key === 'Supported PHY Modes') iface.phyModes = normalizePhyModes(value)
|
|
229
|
+
else if (key === 'Supported Channels') iface.supportedChannels = parseSupportedChannels(value)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (/^\s*<redacted>:\s*$/u.test(line)) {
|
|
234
|
+
closeEntry()
|
|
235
|
+
entry = []
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (entry !== null) entry.push(line)
|
|
239
|
+
}
|
|
240
|
+
closeInterface()
|
|
241
|
+
|
|
242
|
+
return { interfaces }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The 80MHz blocks 802.11 defines across the 5GHz band, and whether each one
|
|
247
|
+
* contains DFS channels (which force a 60-second channel-availability check and
|
|
248
|
+
* let a radar hit vacate the channel).
|
|
249
|
+
*
|
|
250
|
+
* The 5.8GHz band spans 5725–5850MHz = 125MHz, so `149+153+157+161` is the only
|
|
251
|
+
* block that fits there and **160MHz cannot exist in 5.8GHz at all** — that is
|
|
252
|
+
* arithmetic, not a regulatory opinion. 160MHz therefore requires two
|
|
253
|
+
* *adjacent* 80MHz blocks, which in this table means 36–48 + 52–64.
|
|
254
|
+
*/
|
|
255
|
+
export const BLOCKS_5GHZ = Object.freeze([
|
|
256
|
+
Object.freeze({ primary: 36, channels: Object.freeze([36, 40, 44, 48]), dfs: false }),
|
|
257
|
+
Object.freeze({ primary: 52, channels: Object.freeze([52, 56, 60, 64]), dfs: true }),
|
|
258
|
+
Object.freeze({ primary: 100, channels: Object.freeze([100, 104, 108, 112]), dfs: true }),
|
|
259
|
+
Object.freeze({ primary: 116, channels: Object.freeze([116, 120, 124, 128]), dfs: true }),
|
|
260
|
+
Object.freeze({ primary: 132, channels: Object.freeze([132, 136, 140, 144]), dfs: true }),
|
|
261
|
+
Object.freeze({ primary: 149, channels: Object.freeze([149, 153, 157, 161]), dfs: false }),
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
/** The 80MHz block a primary channel belongs to, or `null`. */
|
|
265
|
+
export function blockOf(channel) {
|
|
266
|
+
if (!Number.isFinite(channel)) return null
|
|
267
|
+
return BLOCKS_5GHZ.find(block => block.channels.includes(channel)) ?? null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Collapse a neighbour list into distinct occupancy, one row per
|
|
272
|
+
* (band, width, primary channel).
|
|
273
|
+
*
|
|
274
|
+
* Deduplication must **not** include RSSI: a single AP whose signal wobbles
|
|
275
|
+
* 1–2 dB between scan rounds otherwise counts as several APs. Conversely the
|
|
276
|
+
* merge must prefer a *known* signal over an unknown one, because most beacons
|
|
277
|
+
* carry no signal line at all — a naive `Math.max` over `null → 0` would make
|
|
278
|
+
* the unknown entries win every time.
|
|
279
|
+
*
|
|
280
|
+
* @param neighbors - entries from `parseWifiReport`.
|
|
281
|
+
* @returns rows sorted by band then channel, each with `samples` and `bestRssiDbm`.
|
|
282
|
+
*/
|
|
283
|
+
export function collapseNeighbors(neighbors) {
|
|
284
|
+
const byKey = new Map()
|
|
285
|
+
for (const entry of neighbors) {
|
|
286
|
+
if (entry.channel === null) continue
|
|
287
|
+
const key = `${entry.band}|${entry.widthMhz}|${entry.channel}`
|
|
288
|
+
const row = byKey.get(key) ?? { band: entry.band, widthMhz: entry.widthMhz, channel: entry.channel, samples: 0, bestRssiDbm: UNKNOWN }
|
|
289
|
+
row.samples += 1
|
|
290
|
+
if (entry.rssiDbm !== null && (row.bestRssiDbm === null || entry.rssiDbm > row.bestRssiDbm)) {
|
|
291
|
+
row.bestRssiDbm = entry.rssiDbm
|
|
292
|
+
}
|
|
293
|
+
byKey.set(key, row)
|
|
294
|
+
}
|
|
295
|
+
return [...byKey.values()].sort((a, b) => (a.band === b.band ? a.channel - b.channel : String(a.band).localeCompare(String(b.band))))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Derive the channel plan a host can actually use, from its own report.
|
|
300
|
+
*
|
|
301
|
+
* Answers the questions that decide whether a wider channel is available at all:
|
|
302
|
+
* which 80MHz blocks the regulatory domain permits, which of them neighbours are
|
|
303
|
+
* already sitting on, and whether a 160MHz channel exists (it needs two adjacent
|
|
304
|
+
* permitted blocks, so in CN that is 36–64 *including* DFS 52–64 — the only
|
|
305
|
+
* option, with the mandatory radar check that implies).
|
|
306
|
+
*
|
|
307
|
+
* @param report - output of `parseWifiReport`.
|
|
308
|
+
* @param options.band - band to analyse, default `5GHz`.
|
|
309
|
+
* @returns block table plus the 160MHz verdict.
|
|
310
|
+
*/
|
|
311
|
+
export function summarizeChannels(report, { band = '5GHz' } = {}) {
|
|
312
|
+
const iface = report.interfaces.find(candidate => candidate.supportedChannels.some(entry => entry.band === band))
|
|
313
|
+
?? report.interfaces[0]
|
|
314
|
+
const supported = new Set((iface?.supportedChannels ?? []).filter(entry => entry.band === band).map(entry => entry.channel))
|
|
315
|
+
const neighbors = collapseNeighbors(report.interfaces.flatMap(candidate => candidate.neighbors))
|
|
316
|
+
.filter(entry => entry.band === band)
|
|
317
|
+
|
|
318
|
+
const blocks = BLOCKS_5GHZ.map(block => {
|
|
319
|
+
// An 80MHz block is usable only when the domain grants every 20MHz channel
|
|
320
|
+
// in it; a country table that stops at 48 cannot carry 52–64.
|
|
321
|
+
const permissive = block.channels.every(channel => supported.has(channel))
|
|
322
|
+
const occupants = neighbors.filter(entry => block.channels.includes(entry.channel))
|
|
323
|
+
return {
|
|
324
|
+
primary: block.primary,
|
|
325
|
+
channels: [...block.channels],
|
|
326
|
+
span: `${block.channels[0]}-${block.channels[block.channels.length - 1]}`,
|
|
327
|
+
dfs: block.dfs,
|
|
328
|
+
permitted: permissive,
|
|
329
|
+
occupants,
|
|
330
|
+
occupied: occupants.length,
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// A 160MHz channel is exactly two adjacent 80MHz blocks, so it exists only if
|
|
335
|
+
// the domain permits both halves. In CN that is 36-48 + 52-64 = 36-64, and the
|
|
336
|
+
// DFS half is unavoidable — which is the whole argument against reaching for
|
|
337
|
+
// 160MHz on a 5.8GHz-only-wide deployment.
|
|
338
|
+
const permitted = blocks.filter(block => block.permitted)
|
|
339
|
+
let pair = null
|
|
340
|
+
for (let index = 0; index + 1 < permitted.length; index += 1) {
|
|
341
|
+
const first = permitted[index]
|
|
342
|
+
const second = permitted[index + 1]
|
|
343
|
+
if (second.channels[0] === first.channels[3] + 4) {
|
|
344
|
+
pair = { first, second }
|
|
345
|
+
break
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
band,
|
|
351
|
+
interfaceName: iface?.name ?? null,
|
|
352
|
+
permittedChannels: [...supported].sort((a, b) => a - b),
|
|
353
|
+
blocks,
|
|
354
|
+
width160: pair === null
|
|
355
|
+
? { possible: false, span: null, includesDfs: false, reason: `${band} 里没有两个相邻且被允许的 80MHz 块` }
|
|
356
|
+
: {
|
|
357
|
+
possible: true,
|
|
358
|
+
span: `${pair.first.channels[0]}-${pair.second.channels[3]}`,
|
|
359
|
+
includesDfs: pair.first.dfs || pair.second.dfs,
|
|
360
|
+
reason: '160MHz 必须跨两个相邻 80MHz 块,其中含 DFS 段:需 60s CAC 静默期,且雷达命中即被迫换道',
|
|
361
|
+
},
|
|
362
|
+
widestVerdict: pair === null ? '80' : '160',
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Parse the summary tail of `ping -q` (`round-trip min/avg/max/stddev = …`).
|
|
368
|
+
*
|
|
369
|
+
* @param text - captured stdout of `ping -c N -i I -q <host>`.
|
|
370
|
+
* @returns `{ transmitted, received, lossPercent, minMs, avgMs, maxMs, stddevMs }`
|
|
371
|
+
* with `null` for every statistic ping did not print (a 100% loss run prints
|
|
372
|
+
* no round-trip line at all — reporting `0` there is the same invented-value
|
|
373
|
+
* bug as the 0 dBm one).
|
|
374
|
+
*/
|
|
375
|
+
export function parsePingStats(text) {
|
|
376
|
+
const out = String(text ?? '')
|
|
377
|
+
const counts = /(\d+)\s+packets transmitted,\s*(\d+)\s+(?:packets )?received/u.exec(out)
|
|
378
|
+
const timing = /=\s*([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)\s*ms/u.exec(out)
|
|
379
|
+
const loss = /([\d.]+)%\s*packet loss/u.exec(out)
|
|
380
|
+
const number = value => (value === undefined ? null : Number(value))
|
|
381
|
+
return {
|
|
382
|
+
transmitted: counts === null ? null : Number(counts[1]),
|
|
383
|
+
received: counts === null ? null : Number(counts[2]),
|
|
384
|
+
lossPercent: loss === null ? null : Number(loss[1]),
|
|
385
|
+
minMs: timing === null ? null : number(timing[1]),
|
|
386
|
+
avgMs: timing === null ? null : number(timing[2]),
|
|
387
|
+
maxMs: timing === null ? null : number(timing[3]),
|
|
388
|
+
stddevMs: timing === null ? null : number(timing[4]),
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Parse a single `curl -w` speed sample.
|
|
394
|
+
*
|
|
395
|
+
* @param raw - the `%{speed_download}` value in bytes/second.
|
|
396
|
+
* @returns bytes/second, or `null` when curl reported nothing (a failed
|
|
397
|
+
* transfer must not read as "0 B/s, successfully measured").
|
|
398
|
+
*/
|
|
399
|
+
export function parseSpeedSample(raw) {
|
|
400
|
+
const value = Number(String(raw ?? '').trim())
|
|
401
|
+
return Number.isFinite(value) && value > 0 ? value : null
|
|
402
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fly-guard — DSH Fly 的「持久化写」授权门。
|
|
3
|
+
*
|
|
4
|
+
* 官方 `plugin_manager`(`@deepseek-ai/dsh-plugin-manager/tools`)是**唯一**能把包写进
|
|
5
|
+
* 这份部署的工具:`set_plugin` / `set_bundle` 改的是 `cordis.patch.yml`(profile 层的
|
|
6
|
+
* 覆盖项),`install_bundle` / `remove_bundle` 装/摘的是**会在宿主进程里跑起来**的代码。
|
|
7
|
+
* 这些改动跨会话存活、且绕过我们的装配脚本(`scripts/lib/fly-profile.mjs`)。
|
|
8
|
+
*
|
|
9
|
+
* 它自己的门只有「会话沙箱模式 + 逐次审批」这一层。而**创造模式**就是要一个能改自己的
|
|
10
|
+
* DSH,实测会话里那两项是 `never` + `danger-full-access` —— 也就是**没有门**(本仓库
|
|
11
|
+
* `packages/dsh-plugin-fly-plugins` 的面板一装上去就能看到这一层有多薄)。
|
|
12
|
+
*
|
|
13
|
+
* 所以 Fly 给「写自己」补一道自己的门,判据放在**宿主环境变量**上:
|
|
14
|
+
*
|
|
15
|
+
* 未设 `FLY_ALLOW_PROFILE_WRITE=1` → 只读动作放行,写入动作一律 deny(默认全拒);
|
|
16
|
+
* 设了 → 写入动作也放行(人显式授权)。
|
|
17
|
+
*
|
|
18
|
+
* 为什么是宿主 env 而不是行配置:行配置写在 patch 层里,而 agent 手里**正是**那个能改
|
|
19
|
+
* patch 层的工具 —— 用受它管的门去管它自己,不是门。env 只有人(终端 / Electron 主进程)
|
|
20
|
+
* 能设;会话里的 bash 子进程设不了宿主进程的 env。
|
|
21
|
+
*
|
|
22
|
+
* fail-closed:`plugin_manager` 上出现本文件不认识的 `action`(上游加了新动作),或读不到
|
|
23
|
+
* `action`(形状变了),**一律 deny**。上游 `lib/types/tools.js` 里的 `enum` 是那份词表的
|
|
24
|
+
* 真值来源,`scripts/tests/fly-guard.spec.mjs` 直接对着钉住的闭包逐条比 —— 上游加动作而
|
|
25
|
+
* 这里没跟上,门禁红(不是静默开门)。
|
|
26
|
+
*
|
|
27
|
+
* `action` 读的是 `exec.arguments.action`:引擎交给 `tools/pre-execute` 的 execution 由
|
|
28
|
+
* `@deepseek-ai/dsh-tools` 的 `createExecution()` 造出来,字段是 `arguments`,**没有 `args`**。
|
|
29
|
+
* 黑盒那包曾按 `exec.args?.action` 写、门禁也按 `args` 喂,于是"改动动作被 deny"假绿了
|
|
30
|
+
* 整整一版,真机上 5 个动作根本没进门(见 AGENTS.md 坑表)。
|
|
31
|
+
*/
|
|
32
|
+
export const name = 'fly-guard'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `inject: ['tools']`:这道门挂在**工具面**上(`tools/pre-execute` 是工具执行的
|
|
36
|
+
* waterfall),必须在有工具注册表的那个 scope 里挂载。它自己不注册任何工具,
|
|
37
|
+
* 也不碰 `shell` —— 因此不需要 `shell` / `skills` 那两项。
|
|
38
|
+
*/
|
|
39
|
+
export const inject = ['tools']
|
|
40
|
+
|
|
41
|
+
/** 上游 `plugin_manager` 的只读动作:任何情况下放行。 */
|
|
42
|
+
export const READ_ONLY_ACTIONS = ['list_plugins', 'list_bundles', 'list_version_exemptions']
|
|
43
|
+
|
|
44
|
+
/** 上游 `plugin_manager` 的写入动作:须 `FLY_ALLOW_PROFILE_WRITE=1`。 */
|
|
45
|
+
export const WRITE_ACTIONS = ['set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'set_version_exemption']
|
|
46
|
+
|
|
47
|
+
/** 本门只管这一个工具;其余任何工具原样透传。 */
|
|
48
|
+
export const GATED_TOOL = 'plugin_manager'
|
|
49
|
+
|
|
50
|
+
/** 放行写入的宿主环境变量(人也只能用它的名字,见上面"为什么是宿主 env")。 */
|
|
51
|
+
export const ALLOW_ENV = 'FLY_ALLOW_PROFILE_WRITE'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 从引擎真正交下来的 execution 里读出 `action`。
|
|
55
|
+
*
|
|
56
|
+
* 读不到(没有 `arguments`、`action` 不是非空字符串)返回 `undefined` —— 由调用方
|
|
57
|
+
* **按受控处理**,不是放行。
|
|
58
|
+
*
|
|
59
|
+
* @param exec - `tools/pre-execute` 的 execution 对象。
|
|
60
|
+
* @returns 动作名,或 undefined(读不到)。
|
|
61
|
+
*/
|
|
62
|
+
function actionOf(exec) {
|
|
63
|
+
const action = exec?.arguments?.action
|
|
64
|
+
return typeof action === 'string' && action.trim() !== '' ? action : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 拒绝文案。它要能被人直接读懂"为什么拒、怎么放行",因为 agent 会把原文转给用户。
|
|
69
|
+
*
|
|
70
|
+
* @param action - 被拒的动作名,或 undefined(读不到)。
|
|
71
|
+
* @returns 一段给人看的拒绝理由。
|
|
72
|
+
*/
|
|
73
|
+
function denialReason(action) {
|
|
74
|
+
const what = action === undefined
|
|
75
|
+
? '一个读不出名字的动作(可能是上游换了形状,或加了本门还不认识的新动作)'
|
|
76
|
+
: `\`${action}\``
|
|
77
|
+
return `Fly 的安全门拒绝了 plugin_manager 的 ${what}:任何写入都会**持久**留在 profile 里`
|
|
78
|
+
+ '(patch 层的覆盖项 / bundles / 会在宿主进程里执行的代码),跨会话存活,并且绕过装配脚本。'
|
|
79
|
+
+ `只读动作(${READ_ONLY_ACTIONS.join(' / ')})不受限。`
|
|
80
|
+
+ `确实要写,就请用户在他自己的终端给宿主进程设 ${ALLOW_ENV}=1 后重启 —— `
|
|
81
|
+
+ '这个变量故意不放在会话里,agent 自己设不了它。'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 挂上这道门。
|
|
86
|
+
*
|
|
87
|
+
* 判定顺序就是权限顺序:先看是不是受管工具(不是 ⇒ `next()`,本门对别的工具零影响),
|
|
88
|
+
* 再看是不是声明的只读动作(是 ⇒ 放行),再看人的显式授权(`=1` ⇒ 放行),
|
|
89
|
+
* 剩下的**全拒** —— 未知动作、空动作、读不到 `action`,一个都不漏。
|
|
90
|
+
*
|
|
91
|
+
* @param ctx - Cordis 插件上下文(`inject: ['tools']`)。
|
|
92
|
+
*/
|
|
93
|
+
export function apply(ctx) {
|
|
94
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
95
|
+
if (exec?.name !== GATED_TOOL) return next()
|
|
96
|
+
const action = actionOf(exec)
|
|
97
|
+
if (action !== undefined && READ_ONLY_ACTIONS.includes(action)) return next()
|
|
98
|
+
if (process.env[ALLOW_ENV] === '1') return next()
|
|
99
|
+
return { kind: 'deny', reason: denialReason(action) }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-fly-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH Fly 的持久化写授权门:官方 plugin_manager 的写入动作默认全拒,须 FLY_ALLOW_PROFILE_WRITE=1",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test tests/*.spec.mjs"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ethanchen669/dsh-fly.git",
|
|
21
|
+
"directory": "packages/dsh-plugin-fly-guard"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
}
|
|
26
|
+
}
|