@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,520 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+
3
+ /**
4
+ * fly-binary — ELF / strings / sections toolkit for DSH Fly.
5
+ * Registers model-facing analysis tools on ctx.tools. All command execution
6
+ * goes through the host `shell` capability seam so the sandbox confines it;
7
+ * a tool never shells out via node:child_process directly.
8
+ */
9
+ export const name = 'fly-binary'
10
+ export const inject = ['tools', 'shell']
11
+
12
+ function shq(value) {
13
+ return `'${String(value).replace(/'/g, "'\\''")}'`
14
+ }
15
+
16
+ /**
17
+ * Run one confined command through the host `shell` seam.
18
+ *
19
+ * `ShellExecutor` exposes exactly `resolve()` and `execute()` (verified against
20
+ * 0.1.7-rc.2 and 0.2.0-rc.2): `execute()` returns a process handle whose `result`
21
+ * projection carries the run result. An earlier revision of this file awaited a
22
+ * nonexistent `shell.run()`, so every shell-backed tool threw
23
+ * `TypeError: shell.run is not a function` on every call, in every engine
24
+ * version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
25
+ * names a seam method the executor does not have.
26
+ */
27
+ async function runCmd(shell, argv, exec, opts = {}) {
28
+ const result = await (await shell.execute(shell.resolve({
29
+ command: argv.map(shq).join(' '),
30
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
31
+ ...opts.stdoutMaxBytes !== void 0 ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
32
+ signal: exec.signal,
33
+ }))).result()
34
+ if (result.aborted) {
35
+ const error = new Error('tool call aborted')
36
+ error.name = 'AbortError'
37
+ throw error
38
+ }
39
+ if (result.sandbox?.denied) {
40
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
41
+ error.name = 'SandboxError'
42
+ throw error
43
+ }
44
+ return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command: argv.join(' ') }
45
+ }
46
+
47
+ /**
48
+ * A failed command must never come back as an empty success.
49
+ *
50
+ * Exit 127 (and the shell's "command not found") gets its own wording, because
51
+ * macOS ships no ELF toolchain: `readelf`, `checksec`, `ROPgadget` and `nmap`
52
+ * are simply absent. The old code turned that into "no output", which read
53
+ * exactly like "nothing found".
54
+ */
55
+ function requireOk(run, what) {
56
+ if (run.exitCode === 0) return run.stdout
57
+ const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
58
+ const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
59
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
60
+ error.name = 'CommandError'
61
+ throw error
62
+ }
63
+
64
+ // ── BEGIN fly-elf-probe ────────────────────────────────────────────────────
65
+ // Native ELF reader, duplicated byte-for-byte in fly-binary and fly-pwn. The
66
+ // `verify security-tools` gate compares the two copies and fails when they
67
+ // drift: a mis-parse in one copy would silently mislabel mitigations in the
68
+ // other. Duplicated rather than imported because a plugin ships as a tarball
69
+ // (it cannot reach out of its own package) and because this repo never patches
70
+ // upstream. Why it exists: macOS ships neither `readelf` (GNU binutils) nor
71
+ // `checksec`, and a shipped DMG must not require the user to install Homebrew.
72
+ // File bytes are still read through the sandboxed `shell` seam (`od`); only the
73
+ // decoding happens here.
74
+
75
+ const ELF_MAGIC = 0x7f454c46
76
+ const ELF_CLASSES = { 1: 32, 2: 64 }
77
+ const ELF_TYPES = { 0: 'NONE', 1: 'REL', 2: 'EXEC', 3: 'DYN', 4: 'CORE' }
78
+ const ELF_OSABI = { 0: 'UNIX - System V', 3: 'UNIX - GNU/Linux', 6: 'UNIX - Solaris', 9: 'UNIX - FreeBSD', 12: 'UNIX - OpenBSD' }
79
+ const ELF_MACHINES = {
80
+ 2: 'SPARC', 3: 'Intel 80386', 8: 'MIPS', 20: 'PowerPC', 21: 'PowerPC64', 22: 'IBM S/390',
81
+ 40: 'ARM', 42: 'SuperH', 50: 'IA-64', 62: 'Advanced Micro Devices X86-64', 183: 'AArch64',
82
+ 243: 'RISC-V', 258: 'LoongArch',
83
+ }
84
+ const ELF_PTYPES = {
85
+ 0: 'NULL', 1: 'LOAD', 2: 'DYNAMIC', 3: 'INTERP', 4: 'NOTE', 5: 'SHLIB', 6: 'PHDR', 7: 'TLS',
86
+ 0x6474e550: 'GNU_EH_FRAME', 0x6474e551: 'GNU_STACK', 0x6474e552: 'GNU_RELRO', 0x6474e553: 'GNU_PROPERTY',
87
+ }
88
+ const ELF_DTAGS = {
89
+ 0: 'NULL', 1: 'NEEDED', 2: 'PLTRELSZ', 3: 'PLTGOT', 4: 'HASH', 5: 'STRTAB', 6: 'SYMTAB',
90
+ 7: 'RELA', 8: 'RELASZ', 9: 'RELAENT', 10: 'STRSZ', 11: 'SYMENT', 12: 'INIT', 13: 'FINI',
91
+ 14: 'SONAME', 15: 'RPATH', 16: 'SYMBOLIC', 17: 'REL', 18: 'RELSZ', 19: 'RELENT', 20: 'PLTREL',
92
+ 21: 'DEBUG', 22: 'TEXTREL', 23: 'JMPREL', 24: 'BIND_NOW', 25: 'INIT_ARRAY', 26: 'FINI_ARRAY',
93
+ 27: 'INIT_ARRAYSZ', 28: 'FINI_ARRAYSZ', 29: 'RUNPATH', 30: 'FLAGS', 32: 'PREINIT_ARRAY',
94
+ 33: 'PREINIT_ARRAYSZ', 35: 'RELRSZ', 36: 'RELR', 37: 'RELRENT',
95
+ 0x6ffffef5: 'GNU_HASH', 0x6ffffff0: 'VERSYM', 0x6ffffff9: 'RELACOUNT', 0x6ffffffa: 'RELCOUNT',
96
+ 0x6ffffffb: 'FLAGS_1', 0x6ffffffe: 'VERNEED', 0x6fffffff: 'VERNEEDNUM',
97
+ }
98
+
99
+ const ELF_PT_LOAD = 1
100
+ const ELF_PT_DYNAMIC = 2
101
+ const ELF_PT_INTERP = 3
102
+ const ELF_PT_GNU_STACK = 0x6474e551
103
+ const ELF_PT_GNU_RELRO = 0x6474e552
104
+ const ELF_PF_X = 0x1
105
+ const ELF_DT_HASH = 4
106
+ const ELF_DT_STRTAB = 5
107
+ const ELF_DT_SYMTAB = 6
108
+ const ELF_DT_STRSZ = 10
109
+ const ELF_DT_BIND_NOW = 24
110
+ const ELF_DT_FLAGS = 30
111
+ const ELF_DT_FLAGS_1 = 0x6ffffffb
112
+ const ELF_DF_BIND_NOW = 0x8
113
+ const ELF_DF_1_NOW = 0x1
114
+ const ELF_SHT_DYNAMIC = 6
115
+ const ELF_SHN_UNDEF = 0
116
+
117
+ /**
118
+ * Read `length` bytes at `offset` through the shell seam (`od`) as a Buffer.
119
+ *
120
+ * `run` hands back the raw command result (not just stdout) because `od` refuses
121
+ * to start at or past EOF — "cannot skip past end of input", exit 1 — and that is
122
+ * a SHORT READ, not a failure: a truncated file must yield short tables rather
123
+ * than blowing up the whole tool. Every other non-zero exit (missing file,
124
+ * permission) still goes through `requireOk` and stays loud.
125
+ */
126
+ async function elfReadRange(run, path, offset, length) {
127
+ const result = await run(['od', '-An', '-v', '-tx1', '-j', String(offset), '-N', String(length), path], { stdoutMaxBytes: 8388608 })
128
+ if (result.exitCode !== 0 && !/cannot skip|past end of input/iu.test(result.stderr)) requireOk(result, `od -j ${offset}`)
129
+ const hex = String(result.stdout).replace(/[^0-9a-fA-F]/gu, '')
130
+ return Buffer.from(hex.slice(0, hex.length - (hex.length % 2)), 'hex')
131
+ }
132
+
133
+ /**
134
+ * Parse an ELF file's header, program headers, sections and `.dynamic`.
135
+ *
136
+ * Returns null when the file is not ELF at all (the caller then falls through to
137
+ * its Mach-O path). Anything that could not be read stays `null` and is rendered
138
+ * `unknown` by the caller — never guessed. Every table is bounded by the bytes
139
+ * that actually arrived, so a truncated file yields a short table instead of
140
+ * invented rows.
141
+ */
142
+ async function elfProbe(run, path) {
143
+ const head = await elfReadRange(run, path, 0, 64)
144
+ if (head.length < 16 || head.readUInt32BE(0) !== ELF_MAGIC) return null
145
+ const bits = ELF_CLASSES[head[4]]
146
+ const endian = head[5] === 1 ? 'little' : head[5] === 2 ? 'big' : null
147
+ if (!bits || endian === null) return null
148
+ const wide = bits === 64
149
+ const le = endian === 'little'
150
+ const u16 = (b, o) => (o + 2 <= b.length ? (le ? b.readUInt16LE(o) : b.readUInt16BE(o)) : null)
151
+ const u32 = (b, o) => (o + 4 <= b.length ? (le ? b.readUInt32LE(o) : b.readUInt32BE(o)) : null)
152
+ const u64 = (b, o) => (o + 8 <= b.length ? Number(le ? b.readBigUInt64LE(o) : b.readBigUInt64BE(o)) : null)
153
+
154
+ const eType = u16(head, 16)
155
+ const eMachine = u16(head, 18)
156
+ const entry = wide ? u64(head, 24) : u32(head, 24)
157
+ const phoff = wide ? u64(head, 32) : u32(head, 28)
158
+ const shoff = wide ? u64(head, 40) : u32(head, 32)
159
+ const ehsize = wide ? u16(head, 52) : u16(head, 40)
160
+ const phentsize = wide ? u16(head, 54) : u16(head, 42)
161
+ const phnumRaw = wide ? u16(head, 56) : u16(head, 44)
162
+ const shentsize = wide ? u16(head, 58) : u16(head, 46)
163
+ const shnumRaw = wide ? u16(head, 60) : u16(head, 48)
164
+ const shstrndxRaw = wide ? u16(head, 62) : u16(head, 50)
165
+
166
+ // Section header #0 doubles as the escape hatch for the two "too many to fit"
167
+ // encodings (e_shnum 0, e_phnum/shstrndx 0xffff), so read it first.
168
+ const shdr0 = shoff !== null && shoff > 0 && shentsize !== null && shentsize >= (wide ? 64 : 40)
169
+ ? elfParseSection(await elfReadRange(run, path, shoff, shentsize), wide, le)
170
+ : null
171
+ const shnum = shnumRaw === 0 && shdr0 ? shdr0.size : shnumRaw
172
+ const shstrndx = shstrndxRaw === 0xffff && shdr0 ? shdr0.info : shstrndxRaw
173
+ const phnum = phnumRaw === 0xffff && shdr0 ? shdr0.info : phnumRaw
174
+
175
+ const sections = []
176
+ if (shoff !== null && shoff > 0 && shnum !== null && shnum > 0 && shentsize) {
177
+ const table = await elfReadRange(run, path, shoff, shentsize * shnum)
178
+ for (let i = 0; i < shnum; i++) {
179
+ const at = i * shentsize
180
+ if (at + shentsize > table.length) break
181
+ const section = elfParseSection(table.subarray(at, at + shentsize), wide, le)
182
+ if (section) sections.push({ ...section, index: i })
183
+ }
184
+ }
185
+ const phdrs = []
186
+ if (phoff !== null && phoff > 0 && phnum !== null && phnum > 0 && phentsize) {
187
+ const table = await elfReadRange(run, path, phoff, phentsize * phnum)
188
+ for (let i = 0; i < phnum; i++) {
189
+ const at = i * phentsize
190
+ if (at + phentsize > table.length) break
191
+ const seg = elfParseSegment(table.subarray(at, at + phentsize), wide, le)
192
+ if (seg) phdrs.push({ ...seg, index: i })
193
+ }
194
+ }
195
+
196
+ const shstrtab = sections[shstrndx] ?? null
197
+ if (shstrtab && shstrtab.size > 0) {
198
+ const names = await elfReadRange(run, path, shstrtab.offset, Math.min(shstrtab.size, 65536))
199
+ for (const section of sections) {
200
+ section.name = section.nameOffset < names.length ? elfCString(names, section.nameOffset) : ''
201
+ }
202
+ }
203
+
204
+ const loadSegments = phdrs.filter(p => p.type === ELF_PT_LOAD)
205
+ const readAt = (offset, length) => elfReadRange(run, path, offset, length)
206
+ const fileOffsetOfVaddr = (addr) => {
207
+ const seg = loadSegments.find(p => addr >= p.vaddr && addr < p.vaddr + p.memsz)
208
+ return seg ? seg.offset + (addr - seg.vaddr) : null
209
+ }
210
+ const readVaddr = (addr, length) => {
211
+ const offset = fileOffsetOfVaddr(addr)
212
+ return offset === null ? null : readAt(offset, length)
213
+ }
214
+
215
+ // PT_DYNAMIC is the normal source; a stripped file that kept only the section
216
+ // table still works through .dynamic.
217
+ const dynSegment = phdrs.find(p => p.type === ELF_PT_DYNAMIC)
218
+ const dynSection = sections.find(s => s.type === ELF_SHT_DYNAMIC)
219
+ const dynAt = dynSegment ? { offset: dynSegment.offset, size: dynSegment.filesz }
220
+ : dynSection ? { offset: dynSection.offset, size: dynSection.size } : null
221
+ const dynamic = []
222
+ if (dynAt && dynAt.size > 0) {
223
+ const bytes = await readAt(dynAt.offset, Math.min(dynAt.size, 262144))
224
+ const step = wide ? 16 : 8
225
+ for (let at = 0; at + step <= bytes.length; at += step) {
226
+ const tag = wide ? u64(bytes, at) : u32(bytes, at)
227
+ const val = wide ? u64(bytes, at + (wide ? 8 : 4)) : u32(bytes, at + 4)
228
+ if (tag === null || val === null) break
229
+ dynamic.push({ tag, val })
230
+ if (tag === 0) break
231
+ }
232
+ }
233
+ const dynTag = (tag) => dynamic.find(d => d.tag === tag)
234
+
235
+ const strtab = dynTag(ELF_DT_STRTAB)
236
+ const strsz = dynTag(ELF_DT_STRSZ)
237
+ /** Resolve a byte offset inside DT_STRTAB to its string (null if not resolvable). */
238
+ const dynamicString = async (offsetInStrtab) => {
239
+ if (!strtab || strsz === undefined) return null
240
+ if (!Number.isSafeInteger(offsetInStrtab) || offsetInStrtab < 0 || offsetInStrtab >= strsz.val) return null
241
+ const bytes = await readVaddr(strtab.val + offsetInStrtab, 512)
242
+ return bytes ? elfCString(bytes, 0) : null
243
+ }
244
+
245
+ const stack = phdrs.find(p => p.type === ELF_PT_GNU_STACK) ?? null
246
+ const relro = phdrs.find(p => p.type === ELF_PT_GNU_RELRO) ?? null
247
+ const interp = phdrs.find(p => p.type === ELF_PT_INTERP) ?? null
248
+ const flags = dynTag(ELF_DT_FLAGS)?.val ?? 0
249
+ const flags1 = dynTag(ELF_DT_FLAGS_1)?.val ?? 0
250
+
251
+ return {
252
+ path, bits, endian, type: eType, machine: eMachine, osabi: head[7], entry, ehsize,
253
+ phoff, phnum, phentsize, shoff, shnum, shentsize, shstrndx,
254
+ phdrs, sections, dynamic, dynAt,
255
+ stack, relro, interp, loadSegments,
256
+ bindNow: dynamic.some(d => d.tag === ELF_DT_BIND_NOW)
257
+ || (flags & ELF_DF_BIND_NOW) !== 0 || (flags1 & ELF_DF_1_NOW) !== 0,
258
+ dynamicString, readAt, readVaddr, fileOffsetOfVaddr,
259
+ }
260
+ }
261
+
262
+ /** `R`/`W`/`E` triplet with `-` for absent bits (readelf's three-column flags). */
263
+ function elfFlagsText(pFlags) {
264
+ return `${pFlags & 4 ? 'R' : '-'}${pFlags & 2 ? 'W' : '-'}${pFlags & 1 ? 'E' : '-'}`
265
+ }
266
+
267
+ function elfParseSegment(bytes, wide, le) {
268
+ if (!bytes || bytes.length < (wide ? 56 : 32)) return null
269
+ const u16 = (o) => (le ? bytes.readUInt16LE(o) : bytes.readUInt16BE(o))
270
+ const u32 = (o) => (le ? bytes.readUInt32LE(o) : bytes.readUInt32BE(o))
271
+ const u64 = (o) => Number(le ? bytes.readBigUInt64LE(o) : bytes.readBigUInt64BE(o))
272
+ return wide
273
+ ? { type: u32(0), flags: u32(4), offset: u64(8), vaddr: u64(16), paddr: u64(24), filesz: u64(32), memsz: u64(40), align: u64(48) }
274
+ : { type: u32(0), flags: u32(24), offset: u32(4), vaddr: u32(8), paddr: u32(12), filesz: u32(16), memsz: u32(20), align: u32(28) }
275
+ }
276
+
277
+ function elfParseSection(bytes, wide, le) {
278
+ if (!bytes || bytes.length < (wide ? 64 : 40)) return null
279
+ const u32 = (o) => (le ? bytes.readUInt32LE(o) : bytes.readUInt32BE(o))
280
+ const u64 = (o) => Number(le ? bytes.readBigUInt64LE(o) : bytes.readBigUInt64BE(o))
281
+ return wide
282
+ ? { nameOffset: u32(0), type: u32(4), flags: u64(8), addr: u64(16), offset: u64(24), size: u64(32), link: u32(40), info: u32(44), align: u64(48), entsize: u64(56), name: '' }
283
+ : { nameOffset: u32(0), type: u32(4), flags: u32(8), addr: u32(12), offset: u32(16), size: u32(20), link: u32(24), info: u32(28), align: u32(32), entsize: u32(36), name: '' }
284
+ }
285
+
286
+ function elfCString(bytes, at) {
287
+ let end = at
288
+ while (end < bytes.length && bytes[end] !== 0) end++
289
+ return bytes.subarray(at, end).toString('latin1')
290
+ }
291
+
292
+ /**
293
+ * Dynamic symbol names, used for the canary question ("is `__stack_chk_fail`
294
+ * referenced?"). The symbol count is not stored in ELF; it is recovered from
295
+ * DT_HASH's `nchain`, or by walking DT_GNU_HASH's chains for the highest index.
296
+ * When neither is usable the caller falls back to its documented heuristic.
297
+ */
298
+ async function elfDynamicSymbols(run, elf) {
299
+ const symtab = elf.dynamic.find(d => d.tag === ELF_DT_SYMTAB)
300
+ const strtab = elf.dynamic.find(d => d.tag === ELF_DT_STRTAB)
301
+ if (!symtab || !strtab) return null
302
+ const hash = elf.dynamic.find(d => d.tag === ELF_DT_HASH)
303
+ const gnuHash = elf.dynamic.find(d => d.tag === 0x6ffffef5)
304
+ let count = null
305
+ if (hash) {
306
+ const head = await elf.readVaddr(hash.val, 8)
307
+ if (head && head.length >= 8) count = readU32At(head, 4, elf.endian)
308
+ } else if (gnuHash) {
309
+ const head = await elf.readVaddr(gnuHash.val, 16)
310
+ if (head && head.length >= 16) {
311
+ const nbuckets = readU32At(head, 0, elf.endian)
312
+ const symoffset = readU32At(head, 4, elf.endian)
313
+ const bloomSize = readU32At(head, 8, elf.endian)
314
+ const wordSize = elf.bits === 64 ? 8 : 4
315
+ const bucketsAt = gnuHash.val + 16 + bloomSize * wordSize
316
+ const buckets = await elf.readVaddr(bucketsAt, nbuckets * 4)
317
+ if (buckets && buckets.length >= nbuckets * 4) {
318
+ const chainsAt = bucketsAt + nbuckets * 4
319
+ let max = symoffset
320
+ for (let i = 0; i < nbuckets; i++) {
321
+ let index = readU32At(buckets, i * 4, elf.endian)
322
+ if (index < symoffset) continue
323
+ for (let guard = 0; guard < 65536; guard++) {
324
+ const chain = await elf.readVaddr(chainsAt + (index - symoffset) * 4, 4)
325
+ if (!chain || chain.length < 4) break
326
+ const value = readU32At(chain, 0, elf.endian)
327
+ if ((value & 1) === 1) break
328
+ index++
329
+ }
330
+ if (index + 1 > max) max = index + 1
331
+ }
332
+ count = max
333
+ }
334
+ }
335
+ }
336
+ if (count === null || count <= 0) return null
337
+ const entrySize = elf.bits === 64 ? 24 : 16
338
+ const bytes = await elf.readVaddr(symtab.val, Math.min(count, 65536) * entrySize)
339
+ if (!bytes) return null
340
+ const symbols = []
341
+ for (let i = 0; i < count; i++) {
342
+ const at = i * entrySize
343
+ if (at + entrySize > bytes.length) break
344
+ const nameOffset = readU32At(bytes, at, elf.endian)
345
+ const shndx = elf.bits === 64 ? readU16At(bytes, at + 6, elf.endian) : readU16At(bytes, at + 14, elf.endian)
346
+ const value = elf.bits === 64 ? readU64At(bytes, at + 8, elf.endian) : readU32At(bytes, at + 4, elf.endian)
347
+ const strOffset = strtab.val + nameOffset
348
+ const strBytes = await elf.readVaddr(strOffset, 256)
349
+ symbols.push({ name: strBytes ? elfCString(strBytes, 0) : '', shndx, value })
350
+ }
351
+ return symbols
352
+ }
353
+
354
+ function readU16At(bytes, at, endian) {
355
+ if (at + 2 > bytes.length) return null
356
+ return endian === 'little' ? bytes.readUInt16LE(at) : bytes.readUInt16BE(at)
357
+ }
358
+
359
+ function readU32At(bytes, at, endian) {
360
+ if (at + 4 > bytes.length) return null
361
+ return endian === 'little' ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at)
362
+ }
363
+
364
+ function readU64At(bytes, at, endian) {
365
+ if (at + 8 > bytes.length) return null
366
+ return Number(endian === 'little' ? bytes.readBigUInt64LE(at) : bytes.readBigUInt64BE(at))
367
+ }
368
+ // ── END fly-elf-probe ──────────────────────────────────────────────────────
369
+
370
+ /** Adapter for the shared ELF probe: it wants the raw command result. */
371
+ const rawRun = (shell, exec) => (argv, opts) => runCmd(shell, argv, exec, opts)
372
+
373
+ const elfHex = (value, width = 6) => `0x${Number(value).toString(16).padStart(width, '0')}`
374
+ const elfSay = (value) => (value === null || value === undefined ? 'unknown' : value)
375
+
376
+ function renderElfHeader(elf) {
377
+ return [
378
+ 'ELF Header (parsed natively — this host has no readelf):',
379
+ ` File: ${elf.path}`,
380
+ ` Class: ${elf.bits === 64 ? 'ELF64' : 'ELF32'}`,
381
+ ` Data: 2's complement, ${elf.endian} endian`,
382
+ ` OS/ABI: ${ELF_OSABI[elf.osabi] ?? `0x${Number(elf.osabi).toString(16)}`}`,
383
+ ` Type: ${ELF_TYPES[elf.type] ?? `0x${Number(elf.type).toString(16)}`}`,
384
+ ` Machine: ${ELF_MACHINES[elf.machine] ?? `0x${Number(elf.machine).toString(16)}`}`,
385
+ ` Entry point: ${elfSay(elf.entry === null ? null : elfHex(elf.entry))}`,
386
+ ` Program headers: ${elfSay(elf.phnum)} x ${elfSay(elf.phentsize)} bytes at ${elfSay(elf.phoff === null ? null : elfHex(elf.phoff))}`,
387
+ ` Section headers: ${elfSay(elf.shnum)} x ${elfSay(elf.shentsize)} bytes at ${elfSay(elf.shoff === null ? null : elfHex(elf.shoff))} (shstrndx ${elfSay(elf.shstrndx)})`,
388
+ elf.interp === null ? ' PT_INTERP: none' : ' PT_INTERP: present (dynamically linked executable)',
389
+ ].join('\n')
390
+ }
391
+
392
+ function renderElfSegments(elf) {
393
+ if (elf.phdrs.length === 0) {
394
+ return `no program headers could be read from ${elf.path} (e_phnum=${elfSay(elf.phnum)}) — a relocatable object, or a file whose header is unreadable`
395
+ }
396
+ const rows = elf.phdrs.map(ph => ` ${(ELF_PTYPES[ph.type] ?? `0x${ph.type.toString(16)}`).padEnd(14)} ${elfHex(ph.offset, 8)} ${elfHex(ph.vaddr, 8)} ${elfHex(ph.filesz, 8)} ${elfHex(ph.memsz, 8)} ${elfFlagsText(ph.flags)}`)
397
+ return [
398
+ `Program Headers (${elf.phdrs.length}) — parsed natively:`,
399
+ ' Type Offset VirtAddr FileSiz MemSiz Flg',
400
+ ...rows,
401
+ ].join('\n')
402
+ }
403
+
404
+ async function renderElfDynamic(elf) {
405
+ if (elf.dynamic.length === 0) {
406
+ return `no PT_DYNAMIC segment and no .dynamic section in ${elf.path} — statically linked, so it has no run-time linker entries`
407
+ }
408
+ const lines = [`Dynamic section (${elf.dynamic.length} entries) — parsed natively:`]
409
+ for (const entry of elf.dynamic) {
410
+ const name = ELF_DTAGS[entry.tag] ?? `0x${entry.tag.toString(16)}`
411
+ let value = elfHex(entry.val, 8)
412
+ if (entry.tag === 1 || entry.tag === 14 || entry.tag === 15 || entry.tag === 29) {
413
+ const text = await elf.dynamicString(entry.val)
414
+ value = text === null ? `${elfHex(entry.val, 8)} (string not resolvable)` : `"${text}"`
415
+ }
416
+ lines.push(` ${elfHex(entry.tag, 8)} (${name}) ${value}`)
417
+ }
418
+ return lines.join('\n')
419
+ }
420
+
421
+ export function apply(ctx) {
422
+ const shell = ctx.shell
423
+
424
+ ctx.tools.register(defineTool({
425
+ name: 'bin_file',
426
+ description: 'Identify a binary file (file(1) + size + basic ELF sanity).',
427
+ parameters: {
428
+ path: { type: 'string', required: true, description: 'Absolute path to the binary' },
429
+ },
430
+ output: {
431
+ schema: { type: 'string' },
432
+ render: (_args, value) => [{ type: 'text', text: value }],
433
+ },
434
+ async execute(args, exec) {
435
+ const wc = await runCmd(shell, ['wc', '-c', args.path], exec)
436
+ const size = requireOk(wc, 'wc -c').trim().split(/\s+/)[0] ?? '0'
437
+ const identify = await runCmd(shell, ['file', '-b', args.path], exec)
438
+ return `size=${size}\n${requireOk(identify, 'file -b').trim()}`
439
+ },
440
+ }))
441
+
442
+ ctx.tools.register(defineTool({
443
+ name: 'bin_strings',
444
+ description: 'Extract printable strings from a binary, optionally filtered.',
445
+ parameters: {
446
+ path: { type: 'string', required: true },
447
+ min: { type: 'number', description: 'Minimum string length (default 4)' },
448
+ grep: { type: 'string', description: 'Substring filter' },
449
+ },
450
+ output: {
451
+ schema: { type: 'string' },
452
+ render: (_args, value) => [{ type: 'text', text: value }],
453
+ },
454
+ async execute(args, exec) {
455
+ const min = String(args.min ?? 4)
456
+ const run = await runCmd(shell, ['strings', '-n', min, args.path], exec, { stdoutMaxBytes: 1048576 })
457
+ let lines = requireOk(run, `strings -n ${min}`).split('\n').filter(Boolean)
458
+ if (args.grep) {
459
+ const g = args.grep.toLowerCase()
460
+ lines = lines.filter((l) => l.toLowerCase().includes(g))
461
+ }
462
+ if (lines.length === 0) {
463
+ return args.grep === undefined
464
+ ? `no printable strings of length ${min} or more in ${args.path}`
465
+ : `no printable strings matching ${JSON.stringify(args.grep)} (min length ${min}) in ${args.path}`
466
+ }
467
+ return lines.slice(0, 400).join('\n')
468
+ },
469
+ }))
470
+
471
+ ctx.tools.register(defineTool({
472
+ name: 'bin_readelf',
473
+ description: 'Dump ELF headers / program headers / dynamic section (readelf when installed, else parsed natively).',
474
+ parameters: {
475
+ path: { type: 'string', required: true },
476
+ mode: { type: 'string', description: 'headers | segments | dynamic' },
477
+ },
478
+ output: {
479
+ schema: { type: 'string' },
480
+ render: (_args, value) => [{ type: 'text', text: value }],
481
+ },
482
+ async execute(args, exec) {
483
+ const mode = args.mode === 'segments' || args.mode === 'dynamic' ? args.mode : 'headers'
484
+ const flag = mode === 'segments' ? '-l' : mode === 'dynamic' ? '-d' : '-h'
485
+ // GNU binutils is not part of macOS. When readelf is absent (or refuses the
486
+ // file) the ELF is parsed here instead: the previous revision threw
487
+ // "command not found" on every Mac, which made four of the five binary
488
+ // tools useless on the platform this app ships for.
489
+ const real = await runCmd(shell, ['readelf', flag, args.path], exec)
490
+ if (real.exitCode === 0 && real.stdout.trim() !== '') return real.stdout
491
+ const elf = await elfProbe(rawRun(shell, exec), args.path)
492
+ if (elf === null) {
493
+ const kind = requireOk(await runCmd(shell, ['file', '-b', args.path], exec), 'file -b').trim()
494
+ return [
495
+ `readelf ${flag} is unavailable on this host (${real.exitCode === 127 ? 'not installed' : `exit ${real.exitCode}`})`,
496
+ `${args.path} is not ELF, so a native ELF reader has nothing to report either — file(1) says: ${kind}`,
497
+ 'for Mach-O use rev_disasm / rev_imports (objdump, otool and nm ship with macOS)',
498
+ ].join('\n')
499
+ }
500
+ if (mode === 'segments') return renderElfSegments(elf)
501
+ if (mode === 'dynamic') return await renderElfDynamic(elf)
502
+ return renderElfHeader(elf)
503
+ },
504
+ }))
505
+
506
+ ctx.tools.register(defineTool({
507
+ name: 'bin_sections',
508
+ description: 'List ELF section headers (objdump -h).',
509
+ parameters: {
510
+ path: { type: 'string', required: true },
511
+ },
512
+ output: {
513
+ schema: { type: 'string' },
514
+ render: (_args, value) => [{ type: 'text', text: value }],
515
+ },
516
+ async execute(args, exec) {
517
+ return requireOk(await runCmd(shell, ['objdump', '-h', args.path], exec), 'objdump -h')
518
+ },
519
+ }))
520
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "dsh-plugin-fly-binary",
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-binary"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/"
22
+ }
23
+ }