@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.
Files changed (26) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -2
  3. package/cordis.patch.yml +399 -0
  4. package/package.json +32 -4
  5. package/tools/dsh-plugin-fly-binary/index.js +520 -0
  6. package/tools/dsh-plugin-fly-binary/package.json +23 -0
  7. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +163 -0
  8. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/mitm_addon_template.py +302 -0
  9. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/rules.example.json +52 -0
  10. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/case-ttw.md +157 -0
  11. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/checklist.md +133 -0
  12. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/playbook.md +228 -0
  13. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/rules.schema.md +76 -0
  14. package/tools/dsh-plugin-fly-blackbox/index.js +1453 -0
  15. package/tools/dsh-plugin-fly-blackbox/package.json +31 -0
  16. package/tools/dsh-plugin-fly-diag/index.js +772 -0
  17. package/tools/dsh-plugin-fly-diag/package.json +24 -0
  18. package/tools/dsh-plugin-fly-diag/wifi.js +402 -0
  19. package/tools/dsh-plugin-fly-guard/index.js +101 -0
  20. package/tools/dsh-plugin-fly-guard/package.json +26 -0
  21. package/tools/dsh-plugin-fly-pwn/index.js +583 -0
  22. package/tools/dsh-plugin-fly-pwn/package.json +23 -0
  23. package/tools/dsh-plugin-fly-recon/index.js +184 -0
  24. package/tools/dsh-plugin-fly-recon/package.json +23 -0
  25. package/tools/dsh-plugin-fly-reverse/index.js +178 -0
  26. 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
+ }