@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,184 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+
3
+ /**
4
+ * fly-recon — policy-gated recon tools.
5
+ * Default posture: deny unless FLY_ALLOW_RECON=1 (authorization explicit).
6
+ *
7
+ * One apply() registers BOTH the `tools/pre-execute` guard (a Waterfall whose
8
+ * decision is { kind: "allow" | "deny" }) and the two recon tools. It must be
9
+ * mounted exactly once — as a single agent-preset row — or the tools and the
10
+ * guard collide with themselves in the process-global tool scope.
11
+ *
12
+ * Command execution goes through the host `shell` seam so the sandbox confines
13
+ * it (and the FLY_ALLOW_RECON gate is an ADDITIONAL authorization layer on top
14
+ * of the sandbox, not a replacement for it).
15
+ */
16
+ export const name = 'fly-recon'
17
+ export const inject = ['tools', 'shell']
18
+
19
+ function shq(value) {
20
+ return `'${String(value).replace(/'/g, "'\\''")}'`
21
+ }
22
+
23
+ /**
24
+ * Run one confined command through the host `shell` seam.
25
+ *
26
+ * `ShellExecutor` exposes exactly `resolve()` and `execute()` (verified against
27
+ * 0.1.7-rc.2 and 0.2.0-rc.2): `execute()` returns a process handle whose `result`
28
+ * projection carries the run result. An earlier revision of this file awaited a
29
+ * nonexistent `shell.run()`, so every shell-backed tool threw
30
+ * `TypeError: shell.run is not a function` on every call, in every engine
31
+ * version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
32
+ * names a seam method the executor does not have.
33
+ */
34
+ async function runCmd(shell, argv, exec, opts = {}) {
35
+ const result = await (await shell.execute(shell.resolve({
36
+ command: argv.map(shq).join(' '),
37
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
38
+ ...opts.stdoutMaxBytes !== void 0 ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
39
+ signal: exec.signal,
40
+ }))).result()
41
+ if (result.aborted) {
42
+ const error = new Error('tool call aborted')
43
+ error.name = 'AbortError'
44
+ throw error
45
+ }
46
+ if (result.sandbox?.denied) {
47
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
48
+ error.name = 'SandboxError'
49
+ throw error
50
+ }
51
+ return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command: argv.join(' ') }
52
+ }
53
+
54
+ /**
55
+ * A failed command must never come back as an empty success.
56
+ *
57
+ * `nmap` and `curl` are optional on this host; when they are missing the recon
58
+ * tools must say so instead of reporting an empty scan, because an empty scan
59
+ * reads as "target closed".
60
+ */
61
+ function requireOk(run, what) {
62
+ if (run.exitCode === 0) return run.stdout
63
+ const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
64
+ const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
65
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
66
+ error.name = 'CommandError'
67
+ throw error
68
+ }
69
+
70
+ /**
71
+ * Expand `22,80,8000-8010` into a port list; null when the spec is unreadable, so
72
+ * the tool can say that instead of scanning nothing.
73
+ */
74
+ function expandPorts(spec) {
75
+ const ports = []
76
+ for (const part of String(spec).split(',')) {
77
+ const token = part.trim()
78
+ if (token === '') continue
79
+ const range = /^(\d{1,5})-(\d{1,5})$/u.exec(token)
80
+ if (range !== null) {
81
+ const from = Number(range[1])
82
+ const to = Number(range[2])
83
+ if (from < 1 || to > 65535 || from > to) return null
84
+ for (let port = from; port <= to; port++) ports.push(port)
85
+ } else if (/^\d{1,5}$/u.test(token)) {
86
+ const port = Number(token)
87
+ if (port < 1 || port > 65535) return null
88
+ ports.push(port)
89
+ } else {
90
+ return null
91
+ }
92
+ if (ports.length > 4096) return null
93
+ }
94
+ return ports.length === 0 ? null : [...new Set(ports)]
95
+ }
96
+
97
+ /** Exit 127 / "command not found": the optional tool this host would need is absent. */
98
+ function portMissing(run) {
99
+ return run.exitCode === 127 || /command not found/iu.test(run.stderr)
100
+ }
101
+
102
+ function reconAllowed() {
103
+ return process.env.FLY_ALLOW_RECON === '1'
104
+ }
105
+
106
+ export function apply(ctx) {
107
+ const shell = ctx.shell
108
+
109
+ ctx.on('tools/pre-execute', async (exec, next) => {
110
+ if (exec.name?.startsWith('recon_') && !reconAllowed()) {
111
+ return {
112
+ kind: 'deny',
113
+ reason: 'Recon tools require explicit authorization. Set FLY_ALLOW_RECON=1 for authorized lab targets only.',
114
+ }
115
+ }
116
+ return next()
117
+ })
118
+
119
+ ctx.tools.register(defineTool({
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).',
122
+ parameters: {
123
+ host: { type: 'string', required: true },
124
+ ports: { type: 'string', description: 'Comma list with optional ranges, default top common' },
125
+ },
126
+ output: {
127
+ schema: { type: 'string' },
128
+ render: (_a, v) => [{ type: 'text', text: v }],
129
+ },
130
+ async execute(args, exec) {
131
+ const requested = args.ports === undefined || String(args.ports).trim() === '' ? '22,80,443,445,3389,8080' : String(args.ports)
132
+ const expanded = expandPorts(requested)
133
+ if (expanded === null) {
134
+ return `unparsable port spec ${JSON.stringify(requested)} — use a comma list with optional ranges, e.g. 22,80,8000-8010`
135
+ }
136
+ const nmap = await runCmd(shell, ['nmap', '-Pn', '--open', '-p', expanded.join(','), args.host], exec, { timeoutMs: 60_000 })
137
+ if (nmap.exitCode === 0) return nmap.stdout
138
+ if (!portMissing(nmap)) return requireOk(nmap, 'nmap')
139
+ // `nmap` is not installed by default on macOS. Instead of failing outright
140
+ // (the previous behaviour) or returning an empty scan that reads as "target
141
+ // closed", scan with `nc`, which ships with the base system. The result is
142
+ // labelled: nc cannot tell closed from filtered, and does no fingerprinting.
143
+ if (expanded.length > 64) {
144
+ return [
145
+ `nmap is not installed on this host: the built-in fallback scans with the system nc and is capped at 64 ports per call, but ${expanded.length} were requested`,
146
+ 'narrow the range, or install nmap for a full scan (no 64-port cap, service and version detection)',
147
+ ].join('\n')
148
+ }
149
+ const script = expanded
150
+ .map((port) => `if nc -z -G 1 -w 1 ${shq(args.host)} ${port} >/dev/null 2>&1; then echo '${port}/tcp open'; else echo '${port}/tcp no-response'; fi`)
151
+ .join('; ')
152
+ const scan = await runCmd(shell, ['/bin/sh', '-c', script], exec, { timeoutMs: 120_000 })
153
+ if (scan.exitCode !== 0 && scan.stdout.trim() === '') return requireOk(scan, 'nc -z')
154
+ const rows = scan.stdout.split('\n').map((l) => l.trim()).filter(Boolean)
155
+ return [
156
+ 'nmap=not installed (scanned with the system nc: one TCP connect per port, no service or version detection)',
157
+ `host=${args.host}`,
158
+ `ports=${expanded.length}`,
159
+ ...rows,
160
+ `open=${rows.filter((l) => l.endsWith('/tcp open')).length}`,
161
+ 'note: `no-response` means closed or filtered — nc cannot tell those two apart; install nmap for a real scan',
162
+ ].join('\n')
163
+ },
164
+ }))
165
+
166
+ ctx.tools.register(defineTool({
167
+ name: 'recon_http_fingerprint',
168
+ description: 'Fetch HTTP headers and title from a target URL (authorized lab only).',
169
+ parameters: {
170
+ url: { type: 'string', required: true },
171
+ },
172
+ output: {
173
+ schema: { type: 'string' },
174
+ render: (_a, v) => [{ type: 'text', text: v }],
175
+ },
176
+ async execute(args, exec) {
177
+ const headers = requireOk(await runCmd(shell, ['curl', '-sI', '-m', '8', args.url], exec), `curl -sI ${args.url}`)
178
+ const body = await runCmd(shell, ['curl', '-s', '-m', '8', args.url], exec)
179
+ if (body.exitCode !== 0) return `${headers.trim()}\nTITLE: unknown (body fetch failed, exit ${body.exitCode})`
180
+ const title = /<title[^>]*>([^<]*)<\/title>/iu.exec(body.stdout)?.[1]?.trim() ?? ''
181
+ return `${headers.trim()}\nTITLE: ${title === '' ? '(no <title> element in the body)' : title}`
182
+ },
183
+ }))
184
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "dsh-plugin-fly-recon",
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-recon"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/"
22
+ }
23
+ }
@@ -0,0 +1,178 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+
3
+ /** fly-reverse — imports / disasm helpers feeding the Reverse Lab narrative. */
4
+ export const name = 'fly-reverse'
5
+ export const inject = ['tools', 'shell']
6
+
7
+ function shq(value) {
8
+ return `'${String(value).replace(/'/g, "'\\''")}'`
9
+ }
10
+
11
+ /**
12
+ * Run one confined command through the host `shell` seam.
13
+ *
14
+ * `ShellExecutor` exposes exactly `resolve()` and `execute()` (verified against
15
+ * 0.1.7-rc.2 and 0.2.0-rc.2): `execute()` returns a process handle whose `result`
16
+ * projection carries the run result. An earlier revision of this file awaited a
17
+ * nonexistent `shell.run()`, so every shell-backed tool threw
18
+ * `TypeError: shell.run is not a function` on every call, in every engine
19
+ * version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
20
+ * names a seam method the executor does not have.
21
+ */
22
+ async function runCmd(shell, argv, exec, opts = {}) {
23
+ const result = await (await shell.execute(shell.resolve({
24
+ command: argv.map(shq).join(' '),
25
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
26
+ ...opts.stdoutMaxBytes !== void 0 ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
27
+ signal: exec.signal,
28
+ }))).result()
29
+ if (result.aborted) {
30
+ const error = new Error('tool call aborted')
31
+ error.name = 'AbortError'
32
+ throw error
33
+ }
34
+ if (result.sandbox?.denied) {
35
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
36
+ error.name = 'SandboxError'
37
+ throw error
38
+ }
39
+ return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command: argv.join(' ') }
40
+ }
41
+
42
+ /**
43
+ * A failed command must never come back as an empty success.
44
+ *
45
+ * Exit 127 (and the shell's "command not found") gets its own wording, because
46
+ * macOS ships no ELF toolchain: `readelf`, `checksec`, `ROPgadget` and `nmap`
47
+ * are simply absent. The old code turned that into "no output", which read
48
+ * exactly like "nothing found".
49
+ */
50
+ function requireOk(run, what) {
51
+ if (run.exitCode === 0) return run.stdout
52
+ const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
53
+ const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
54
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
55
+ error.name = 'CommandError'
56
+ throw error
57
+ }
58
+
59
+ /**
60
+ * Gadgets ending in a return, read straight out of `objdump -d`.
61
+ *
62
+ * This is the fallback for hosts without ROPgadget (any stock macOS). The
63
+ * previous fallback demanded `/\bret\b/` and `pop|leave` on the SAME LINE:
64
+ * objdump prints `popq %rdi` and `retq` on separate lines, and `retq` does not
65
+ * match `/\bret\b/` — so a textbook `pop rdi; ret` came back as "no gadgets
66
+ * found" (asserted in scripts/verify-security-tools.mjs).
67
+ */
68
+ function scanGadgets(disasm, filter) {
69
+ const instruction = /^\s*([0-9a-f]+):\s+(?:[0-9a-f]{2} )+\s*([a-z][a-z0-9.]*)\s*(.*)$/u
70
+ const wanted = filter === undefined ? null : filter.toLowerCase().split(/\s+/u).filter(Boolean)
71
+ const recent = []
72
+ const found = []
73
+ for (const line of disasm.split('\n')) {
74
+ const match = instruction.exec(line)
75
+ if (match === null) continue
76
+ const [, address, mnemonic, operands] = match
77
+ const text = `${mnemonic}${operands.trim() === '' ? '' : ` ${operands.trim()}`}`
78
+ if (mnemonic.startsWith('ret') === false) {
79
+ recent.push({ address, text })
80
+ if (recent.length > 5) recent.shift()
81
+ continue
82
+ }
83
+ if (recent.length === 0) continue
84
+ const gadget = `0x${recent[0].address}: ${recent.map(step => step.text).join(' ; ')} ; ret`
85
+ if (wanted === null || wanted.every(token => gadget.toLowerCase().includes(token))) found.push(gadget)
86
+ recent.length = 0
87
+ if (found.length >= 200) break
88
+ }
89
+ return found
90
+ }
91
+
92
+ export function apply(ctx) {
93
+ const shell = ctx.shell
94
+
95
+ ctx.tools.register(defineTool({
96
+ name: 'rev_imports',
97
+ description: 'List imported (undefined) symbols: objdump -T for ELF, nm -u for Mach-O and stripped files.',
98
+ parameters: {
99
+ path: { type: 'string', required: true },
100
+ },
101
+ output: {
102
+ schema: { type: 'string' },
103
+ render: (_a, v) => [{ type: 'text', text: v }],
104
+ },
105
+ async execute(args, exec) {
106
+ const kindRun = await runCmd(shell, ['file', '-b', args.path], exec)
107
+ const kind = kindRun.exitCode === 0 ? kindRun.stdout.trim() : ''
108
+ const dynamic = await runCmd(shell, ['objdump', '-T', args.path], exec)
109
+ const dynamicLines = dynamic.exitCode === 0
110
+ ? dynamic.stdout.split('\n').filter((l) => l.includes('DF') && l.includes('*UND*')).map((l) => l.trim())
111
+ : []
112
+ if (dynamicLines.length > 0) return dynamicLines.slice(0, 300).join('\n')
113
+ // `objdump -T` exits 0 with a bare table header for Mach-O, and a stripped
114
+ // ELF may have no dynamic symbol table at all. `nm -u` is the portable
115
+ // second opinion (it ships with macOS and the Xcode CLT).
116
+ const nm = await runCmd(shell, ['nm', '-u', args.path], exec, { stdoutMaxBytes: 4194304 })
117
+ const undefinedSymbols = nm.exitCode === 0
118
+ ? nm.stdout.split('\n').map((l) => l.trim()).filter((l) => /^_?[A-Za-z_][\w$.@]*$/u.test(l))
119
+ : []
120
+ if (undefinedSymbols.length > 0) {
121
+ return [
122
+ `${kind === '' ? 'format unknown' : kind}: no ELF dynamic symbol table to read, so these are the undefined symbols from nm -u (${undefinedSymbols.length}):`,
123
+ ...undefinedSymbols.slice(0, 300),
124
+ ].join('\n')
125
+ }
126
+ return [
127
+ `no undefined symbols for ${args.path}: objdump -T (exit ${dynamic.exitCode}) and nm -u (exit ${nm.exitCode}) both came back empty`,
128
+ `format: ${kind === '' ? 'unknown (file(1) reported nothing)' : kind}`,
129
+ `nm -u stderr: ${nm.stderr.trim().slice(0, 200) || '(empty)'}`,
130
+ `objdump -T output follows (${dynamic.stdout.trim().length} chars), so the emptiness is inspectable:`,
131
+ dynamic.stdout.trim().slice(0, 2000),
132
+ ].join('\n')
133
+ },
134
+ }))
135
+
136
+ ctx.tools.register(defineTool({
137
+ name: 'rev_disasm',
138
+ description: 'Disassemble a binary or a function window.',
139
+ parameters: {
140
+ path: { type: 'string', required: true },
141
+ start: { type: 'string', description: 'Optional start address, e.g. 0x4011a6' },
142
+ stop: { type: 'string', description: 'Optional stop address' },
143
+ },
144
+ output: {
145
+ schema: { type: 'string' },
146
+ render: (_a, v) => [{ type: 'text', text: v }],
147
+ },
148
+ async execute(args, exec) {
149
+ const argv = ['-d', args.path]
150
+ if (args.start && args.stop) argv.splice(1, 0, `--start-address=${args.start}`, `--stop-address=${args.stop}`)
151
+ const stdout = requireOk(await runCmd(shell, ['objdump', ...argv], exec, { stdoutMaxBytes: 1048576 }), 'objdump -d')
152
+ return stdout.slice(0, 12000)
153
+ },
154
+ }))
155
+
156
+ ctx.tools.register(defineTool({
157
+ name: 'rev_gadget_scan',
158
+ description: 'Scan for simple ROP gadgets with ROPgadget if installed, else a built-in objdump scanner.',
159
+ parameters: {
160
+ path: { type: 'string', required: true },
161
+ filter: { type: 'string', description: 'Optional gadget filter, e.g. "pop rdi"' },
162
+ },
163
+ output: {
164
+ schema: { type: 'string' },
165
+ render: (_a, v) => [{ type: 'text', text: v }],
166
+ },
167
+ async execute(args, exec) {
168
+ const argv = ['--binary', args.path, '--only', 'pop|ret|leave']
169
+ if (args.filter) argv.push('--filter', args.filter)
170
+ const rop = await runCmd(shell, ['ROPgadget', ...argv], exec)
171
+ if (rop.exitCode === 0) return rop.stdout.slice(0, 8000)
172
+ const disasm = requireOk(await runCmd(shell, ['objdump', '-d', args.path], exec, { stdoutMaxBytes: 1048576 }), 'objdump -d')
173
+ const gadgets = scanGadgets(disasm, args.filter)
174
+ if (gadgets.length > 0) return gadgets.slice(0, 80).join('\n')
175
+ return `no pop/leave/ret gadgets found in ${args.path} (install ROPgadget for a deeper scan)`
176
+ },
177
+ }))
178
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "dsh-plugin-fly-reverse",
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-reverse"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/"
22
+ }
23
+ }