SLOPSHOPPER

droid-dna-ui

droid-dna pane: browse the sample catalog, scan APKs and read verdicts.

newpanecommandtoast
v0.1.0no licenseupdated 2026-10-20jmaitlandsoto/droid-dna/droid-dna-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · droid-dna-ui
│ ┃ droid-dna ✕ › fix the failing auth test and add an audit log call │ ┃ [ [Start] ] [ Job History ] │ ┃ JSON Parse error: Unexpected EOF ⏺ Read(src/auth.ts) │ ┃ Pick an APK to analyze. ⎿ Read 6 lines │ ┃ [ From device ] [ MalwareBazaar ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /droid-dna │ ⎿ droid-dna-ui: droid-dna pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · droid-dna
[ [Start] ] [ Job History ] JSON Parse error: Unexpected EOF Pick an APK to analyze. [ From device ] [ MalwareBazaar ]
README

droid-dna

APK malware triage for Claude Code, run straight from this repo. Cheap static checks run first; Claude investigates only what they cannot clear, using the REA reverse-engineering MCP server. Verdicts are evidence-backed and say "no indicators found", never "safe".

Status: early development. /scan works end to end on one platform (Linux x64): fetch or import, static triage, escalation to a team of read-only agents (a planner, parallel investigators and a verdict writer, working from a checklist the server enforces), and a structured verdict. See docs/droid-dna-design.md for the plan.

Get started

git clone <this repo> && cd droid-dna
npm run setup      # or: npx droid-dna setup
claude

Setup provisions the toolchain and, if Claude Code is installed, enables the droid-dna-ui plugin (the pane) at project scope in .claude/settings.json. If Claude Code is missing it says so; install it and run setup again. Approve the project MCP servers (core, rea) and the plugin when Claude Code asks, then:

  • /scan <sha256|path> scans a sample
  • /search <text> searches the local catalog
  • /droid-dna opens the pane (Start and Job History tabs; From device, MalwareBazaar library, Scan, job details with Deep and Ask Claude)

Everything the tool needs is in the repo: CLAUDE.md for the rules, .mcp.json for the servers, .claude/ for the skill, agents and commands.

Requirements

  • Claude Code
  • Node.js 18+ (setup fetches Node 22 for REA itself if yours is older)
  • Network access during setup

npm run setup downloads a JDK (checksum from Adoptium) and the JADX bridge JAR (pinned SHA-256) into ~/.droid-dna/tools/, about 250 MB, and creates ~/.droid-dna/.env. npm run check reports what is missing without changing anything. If you used this project before the rename, setup moves ~/.readroid to ~/.droid-dna; READROID_HOME is now DROID_DNA_HOME.

Optional: MalwareBazaar key

To fetch known-malware samples, put a free abuse.ch key in ~/.droid-dna/.env (see .env.example). Keep it outside any repository.

Safety

  • Static analysis only. Nothing is installed or executed.
  • The agents can use only read-only Android tools from REA, listed one by one. REA also exposes adb install, Frida and debugger tools, which the agents cannot call.
  • Everything inside a sample is treated as data, never as instructions.
  • Samples stay in encrypted zips under ~/.droid-dna/samples/. This repository never contains samples.

Layout

CLAUDE.md                 rules and orientation for Claude
.mcp.json                 core + rea MCP servers
.claude/                  skills/triage, agents/, commands/
droid-dna/                bin/ (setup, REA launcher), core/ (MCP server), lib/, test/
droid-dna-ui/             optional pane mod
docs/                     design doc and the original reference

Known issues

  • If you also have a user-level MCP server named rea, it can shadow the project's and lacks REA_JADX_MCP_JAR, so decompile tools fail. npm run setup warns about it; remove it with claude mcp remove rea -s user.
  • apktool is not provisioned, so decoded resource XML (for example an accessibility service's config flags) may be unreadable.
  • Native payloads are only inspected through sample_inspect_native (headers, imports and a sample of strings). Questions that need disassembly end unresolved with blocker no_native_tool, which lowers the verdict's confidence.
Source 2 files
hooks/register.tsx 410 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { DirEntry, JobDetail, LibraryRow, Route, ScanRow } from '../types'
5
6const PANE = 'droid-dna'
7
8const route = atom({ plugin: 'droid-dna-ui', key: 'route' } as const, [{ page: 'start' }] as Route[])
9const results = atom({ plugin: 'droid-dna-ui', key: 'results' } as const, [] as LibraryRow[])
10const scans = atom({ plugin: 'droid-dna-ui', key: 'scans' } as const, [] as ScanRow[])
11const job = atom({ plugin: 'droid-dna-ui', key: 'job' } as const, null as JobDetail | null)
12const dir = atom({ plugin: 'droid-dna-ui', key: 'dir' } as const, '')
13const entries = atom({ plugin: 'droid-dna-ui', key: 'entries' } as const, [] as DirEntry[])
14const busy = atom({ plugin: 'droid-dna-ui', key: 'busy' } as const, {} as Record<string, string>)
15const notice = atom({ plugin: 'droid-dna-ui', key: 'notice' } as const, '')
16
17// The project's .mcp.json names the server `core`.
18const SERVERS = ['core']
19
20type Core = (tool: string, args?: Record<string, unknown>) => Promise<any>
21
22const short = (sha: string) => sha.slice(0, 10)
23const when = (iso: string | null) => (iso ? iso.slice(0, 16).replace('T', ' ') : '')
24
25let server: string | null = null
26
27const makeCore = ($: any): Core => async (tool, args) => {
28  const names = server ? [server] : SERVERS
29  let last: unknown
30  for (const name of names) {
31    try {
32      const res = await $.mcp.call(name, tool, args ?? {})
33      server = name
34      const text = res.content?.[0]?.text ?? ''
35      if (res.isError) throw new Error(text)
36      return JSON.parse(text)
37    } catch (err) {
38      last = err
39      if (server) break
40    }
41  }
42  if (/no connected MCP tool/.test(String((last as Error)?.message ?? last)))
43    throw new Error(
44      'the droid-dna core server is not running. Open /mcp, enable the project servers `core` and `rea`, and reload the pane (if they are not listed, run `npm run setup` and restart Claude Code)',
45    )
46  throw last
47}
48
49const say = ($: any, text: string) => update($, notice, () => text)
50const fail = ($: any) => (err: unknown) => say($, (err as Error).message)
51
52// ---- navigation: a stack whose bottom is a tab page (start or jobs) ----
53const push = ($: any, page: Route) => update($, route, r => [...r, page])
54const pop = ($: any) => update($, route, r => (r.length > 1 ? r.slice(0, -1) : r))
55const setRoot = ($: any, page: 'start' | 'jobs') => update($, route, () => [{ page }])
56
57// ---- data ----
58const loadScans = async ($: any) => {
59  const list = await makeCore($)('scan_list', {})
60  await update($, scans, () =>
61    list.map((s: any): ScanRow => ({
62      id: s.id,
63      sha256: s.sha256,
64      stage: s.stage,
65      startedAt: s.startedAt ?? null,
66      verdict: s.verdict ?? null,
67      confidence: s.confidence ?? null,
68      confidenceRequested: s.confidenceRequested ?? null,
69      score: s.score ?? null,
70      escalate: s.escalate ?? null,
71      summary: s.summary ?? null,
72      error: s.error ?? null,
73      findingCount: s.findingCount ?? 0,
74      checklist: s.ledger ?? null,
75    })),
76  )
77}
78
79const loadLibrary = async ($: any) => {
80  const found = await makeCore($)('catalog_search', { label: 'malicious', limit: 200 })
81  await update($, results, () =>
82    found.results.filter((r: any) => r.sources.includes('malwarebazaar')).map((r: any): LibraryRow => ({
83      sha256: r.sha256,
84      label: r.pkg ?? r.name ?? short(r.sha256),
85      size: r.sizeBytes ?? null,
86      onDisk: r.onDisk,
87      tags: r.labels.join(', '),
88    })),
89  )
90}
91
92const loadJob = async ($: any, sha256: string, id: number) => {
93  await update($, job, () => null)
94  const s = await makeCore($)('scan_get', { sha256, id })
95  await update($, job, () => ({
96    id: s.id,
97    sha256: s.sha256,
98    stage: s.stage,
99    startedAt: s.startedAt ?? null,
100    verdict: s.verdict ?? null,
101    confidence: s.confidence ?? null,
102    confidenceRequested: s.confidenceRequested ?? null,
103    score: s.score ?? null,
104    escalate: s.escalate ?? null,
105    summary: s.summary ?? null,
106    error: s.error ?? null,
107    findingCount: s.findings?.length ?? 0,
108    checklist: s.ledger ?? null,
109    confidenceNote: s.confidenceNote ?? null,
110    categories: s.categories ?? [],
111    findings: s.findings ?? [],
112    unknowns: s.unknowns ?? [],
113    endpoints: (s.endpoints ?? []).map((e: unknown) => (typeof e === 'string' ? e : JSON.stringify(e))),
114  }))
115}
116
117const parentOf = (p: string) => {
118  const i = p.replace(/\/+$/, '').lastIndexOf('/')
119  return i <= 0 ? '/' : p.slice(0, i)
120}
121const joinPath = (a: string, b: string) => (a.endsWith('/') ? a : a + '/') + b
122
123const listDir = async ($: any, path: string) => {
124  const raw = await $.fs.list(path)
125  const rows: DirEntry[] = raw
126    .filter((f: any) => !f.name.startsWith('.') && (f.kind === 'dir' || (f.kind === 'file' && /\.apk$/i.test(f.name))))
127    .map((f: any): DirEntry => ({ name: f.name, kind: f.kind === 'dir' ? 'dir' : 'apk' }))
128    .sort((a: DirEntry, b: DirEntry) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1))
129  await update($, dir, () => path)
130  await update($, entries, () => rows)
131}
132
133const browse = ($: any, path: string) =>
134  listDir($, path).then(() => say($, ''), fail($))
135
136const openDevice = async ($: any) => {
137  await push($, { page: 'device' })
138  if ((await read($, dir)) === '') browse($, (await $.env.get('HOME')) || '/')
139  else void say($, '')
140}
141
142const openLibrary = async ($: any) => {
143  await push($, { page: 'library' })
144  await loadLibrary($).catch(fail($))
145}
146
147const openJob = async ($: any, s: ScanRow) => {
148  await push($, { page: 'job', sha256: s.sha256, id: s.id })
149  await loadJob($, s.sha256, s.id).catch(fail($))
150}
151
152// Buttons enqueue; work runs detached because a hook never awaits a whole scan.
153// `path` imports a local APK first; otherwise the sample is fetched from the catalog.
154const startScan = ($: any, target: { sha256?: string; path?: string }) => {
155  void (async () => {
156    const core = makeCore($)
157    let sha = target.sha256 ?? ''
158    const key = sha || target.path || ''
159    const mark = (msg: string | null) =>
160      update($, busy, b => {
161        const next = { ...b }
162        if (msg === null) delete next[key]
163        else next[key] = msg
164        return next
165      })
166    if ((await read($, busy))[key]) return
167    try {
168      if (target.path) {
169        await mark('importing')
170        sha = (await core('sample_import', { path: target.path })).sha256
171      } else {
172        await mark('fetching')
173        await core('sample_fetch', { sha256: sha })
174      }
175      await mark('triage')
176      const t = await core('scan_triage', { sha256: sha })
177      await mark(null)
178      await loadScans($)
179      await say(
180        $,
181        t.escalate
182          ? `${short(sha)}: score ${t.score}, needs deep analysis (open it in Job History and press Deep).`
183          : `${short(sha)}: score ${t.score}, no indicators found.`,
184      )
185      $.ui.toast(`Triage finished: ${short(sha)} score ${t.score}`)
186    } catch (err) {
187      await mark(null)
188      await say($, `${short(key)}: ${(err as Error).message}`)
189    }
190  })()
191}
192
193const askDeep = ($: any, s: ScanRow) => {
194  $.ui.toast(`Deep analysis requested for ${short(s.sha256)}`)
195  void say($, `Deep analysis requested for ${short(s.sha256)}; Claude is working in the main conversation.`)
196  void $.prompt.submit({
197    text: `Run a deep droid-dna scan of ${s.sha256}. Follow the triage skill: call scan_triage for a fresh scan if the latest one is already done, then run its deep analysis (checklist, planner, parallel investigators, verdict agent), call sample_close, and report the verdict from scan_list.`,
198    asUser: true,
199  })
200}
201
202const askClaude = ($: any, s: ScanRow) => {
203  $.ui.toast(`Asking Claude about ${short(s.sha256)}`)
204  void $.prompt.submit({
205    text: `Explain the droid-dna result for ${s.sha256} (scan ${s.id}). Use scan_get and say what was not examined.`,
206    asUser: true,
207  })
208}
209
210const verdictColor = (s: ScanRow) =>
211  s.verdict === 'malicious' || s.stage === 'error' ? 'red'
212    : s.verdict === 'proxyware' || s.verdict === 'pup' ? 'yellow'
213    : s.verdict === 'no_indicators' ? 'green'
214    : 'white'
215
216const scoreLine = (s: ScanRow) =>
217  [
218    s.confidence !== null && `conf ${s.confidence}${s.confidenceRequested !== null ? ` (lowered from ${s.confidenceRequested})` : ''}`,
219    s.score !== null && `score ${s.score}`,
220    `${s.findingCount} finding${s.findingCount === 1 ? '' : 's'}`,
221    s.checklist && `checklist ${s.checklist.total - s.checklist.open}/${s.checklist.total}`,
222  ].filter(Boolean).join(' · ')
223
224export const register: Register = on => {
225  on('session.start', async ($, e, next) => {
226    await $.command.register({
227      name: 'droid-dna',
228      description: 'Open the droid-dna pane',
229    })
230    return next(e)
231  })
232
233  on('command.run', { command: 'droid-dna' }, async $ => {
234    await $.ui.open({ id: PANE, title: 'droid-dna', focus: true })
235    await loadScans($).catch(fail($))
236    return { text: 'droid-dna pane opened.' }
237  })
238
239  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
240    const { Box, Text, Button, Input } = $.ui.resolve(e)
241    const [stack, found, sc, detail, cwd, files, bz, msg] = await Promise.all([
242      read($, route), read($, results), read($, scans), read($, job),
243      read($, dir), read($, entries), read($, busy), read($, notice),
244    ])
245    const top = stack[stack.length - 1]
246    const canType = e.surface !== 'mobile'
247    const nested = stack.length > 1
248
249    const title: Record<Route['page'], string> = {
250      start: 'Start', jobs: 'Job History', device: 'From device', library: 'MalwareBazaar library', job: 'Job details',
251    }
252
253    const header = nested ? (
254      <Box>
255        <Button key="back" label="< Back" hotkey="b" onPress={() => { void pop($) }} />
256        <Text bold> {title[top.page]}</Text>
257      </Box>
258    ) : (
259      <Box>
260        <Button key="tab-start" label={top.page === 'start' ? '[Start]' : 'Start'} hotkey="s"
261          onPress={() => { void setRoot($, 'start') }} />
262        <Text> </Text>
263        <Button key="tab-jobs" label={top.page === 'jobs' ? '[Job History]' : 'Job History'} hotkey="j"
264          onPress={() => { void setRoot($, 'jobs'); void loadScans($).catch(fail($)) }} />
265      </Box>
266    )
267
268    const scanButton = (sha: string) =>
269      bz[sha]
270        ? <Text color="cyan">{bz[sha]}…</Text>
271        : <Button key={`scan-${sha}`} label="Scan" onPress={() => startScan($, { sha256: sha })} />
272
273    let body: any = null
274
275    if (top.page === 'start') {
276      body = (
277        <Box flexDirection="column">
278          <Text dimColor>Pick an APK to analyze.</Text>
279          <Box>
280            <Button key="from-device" label="From device" hotkey="d" onPress={() => { void openDevice($) }} />
281            <Text> </Text>
282            <Button key="open-library" label="MalwareBazaar" hotkey="m" onPress={() => { void openLibrary($) }} />
283          </Box>
284        </Box>
285      )
286    }
287
288    if (top.page === 'device') {
289      body = (
290        <Box flexDirection="column">
291          {canType
292            ? (
293              <Input key="path" label="Folder" placeholder="/path/to/folder" value={cwd}
294                submitLabel="open"
295                onInput={(text: string) => update($, dir, () => text)}
296                onSubmit={() => { void browse($, cwd) }} />
297            )
298            : <Text dimColor>Type a path with /scan &lt;path to .apk&gt; in the prompt.</Text>}
299          {cwd !== '' && <Text dimColor>{cwd}</Text>}
300          {cwd !== '' && cwd !== '/' && (
301            <Button key="up" label=".. (up)" onPress={() => { void browse($, parentOf(cwd)) }} />
302          )}
303          {files.length === 0 && cwd !== '' && <Text dimColor>No folders or .apk files here.</Text>}
304          {files.map(f => {
305            const path = joinPath(cwd, f.name)
306            return f.kind === 'dir'
307              ? <Button key={`dir-${f.name}`} label={`${f.name}/`} onPress={() => { void browse($, path) }} />
308              : bz[path]
309                ? <Text key={`apk-${f.name}`} color="cyan">{f.name} {bz[path]}…</Text>
310                : (
311                  <Button key={`apk-${f.name}`} label={`${f.name}  [scan]`} onPress={() => {
312                    startScan($, { path })
313                    void setRoot($, 'jobs')
314                  }} />
315                )
316          })}
317        </Box>
318      )
319    }
320
321    if (top.page === 'library') {
322      body = (
323        <Box flexDirection="column">
324          {found.length === 0 && <Text dimColor>No MalwareBazaar samples in the catalog. Ask Claude to run catalog_sync.</Text>}
325          {found.map(r => (
326            <Box key={r.sha256}>
327              <Text>{short(r.sha256)} {r.label.slice(0, 32)} <Text dimColor>{r.tags}</Text> </Text>
328              {scanButton(r.sha256)}
329            </Box>
330          ))}
331        </Box>
332      )
333    }
334
335    if (top.page === 'jobs') {
336      body = (
337        <Box flexDirection="column">
338          {sc.length === 0 && <Text dimColor>No analysis jobs yet. Use Start to scan an APK.</Text>}
339          {sc.map(s => (
340            <Box key={`${s.sha256}.${s.id}`} flexDirection="column" borderStyle="round" paddingX={1}>
341              <Box>
342                <Button key={`job-${s.sha256}.${s.id}`} label={`${short(s.sha256)} #${s.id}`} onPress={() => { void openJob($, s) }} />
343                <Text> </Text>
344                <Text color={verdictColor(s)}>{s.verdict ?? s.stage}</Text>
345                <Text dimColor> {when(s.startedAt)}</Text>
346              </Box>
347              <Text dimColor>{scoreLine(s)}</Text>
348              {(s.summary || s.error) && <Text dimColor>{(s.summary ?? s.error ?? '').slice(0, 110)}</Text>}
349            </Box>
350          ))}
351        </Box>
352      )
353    }
354
355    if (top.page === 'job') {
356      const s = detail && detail.sha256 === top.sha256 && detail.id === top.id ? detail : null
357      body = s === null ? <Text dimColor>Loading…</Text> : (
358        <Box flexDirection="column">
359          <Text>{s.sha256}</Text>
360          <Box>
361            <Text color={verdictColor(s)} bold>{s.verdict ?? s.stage}</Text>
362            <Text dimColor> scan #{s.id} {when(s.startedAt)}</Text>
363          </Box>
364          <Text dimColor>{scoreLine(s)}</Text>
365          {s.confidenceNote && <Text color="yellow">{s.confidenceNote}</Text>}
366          {s.categories.length > 0 && <Text>Categories: {s.categories.join(', ')}</Text>}
367          {s.summary && <Text>{s.summary}</Text>}
368          {s.error && <Text color="red">{s.error}</Text>}
369          <Box>
370            {(s.stage === 'triaged' || (s.verdict === 'inconclusive' && s.escalate)) && (
371              <Button key="deep" label={s.stage === 'triaged' ? 'Deep' : 'Retry deep'} onPress={() => askDeep($, s)} />
372            )}
373            <Text> </Text>
374            <Button key="ask" label="Ask Claude" onPress={() => askClaude($, s)} />
375          </Box>
376
377          <Text bold>Findings</Text>
378          {s.findings.length === 0 && <Text dimColor>No indicators found in what was examined.</Text>}
379          {s.findings.map((f, i) => (
380            <Box key={`f-${i}`} flexDirection="column">
381              <Text>
382                <Text color={f.severity === 'high' ? 'red' : f.severity === 'medium' ? 'yellow' : 'white'}>{f.severity}</Text>
383                {' '}{f.title} <Text dimColor>{f.confirmed ? 'confirmed' : 'unconfirmed'} · {f.rule_id}</Text>
384              </Text>
385              {f.evidence.length > 0 && (
386                <Text dimColor>  {(typeof f.evidence[0] === 'string' ? f.evidence[0] : JSON.stringify(f.evidence[0])).slice(0, 160)}</Text>
387              )}
388            </Box>
389          ))}
390
391          {s.endpoints.length > 0 && <Text bold>Endpoints</Text>}
392          {s.endpoints.map((x, i) => <Text key={`e-${i}`} dimColor>{x.slice(0, 160)}</Text>)}
393
394          <Text bold>Not examined</Text>
395          {s.unknowns.length === 0 && <Text dimColor>Nothing recorded.</Text>}
396          {s.unknowns.map((u, i) => <Text key={`u-${i}`} dimColor>{u.slice(0, 200)}</Text>)}
397        </Box>
398      )
399    }
400
401    return (
402      <Box flexDirection="column">
403        {header}
404        {msg !== '' && <Text color="yellow">{msg}</Text>}
405        {body}
406      </Box>
407    )
408  })
409}
410
types/index.d.ts 64 lines
1export type LibraryRow = {
2  sha256: string
3  label: string
4  size: number | null
5  onDisk: boolean
6  tags: string
7}
8
9export type ScanRow = {
10  id: number
11  sha256: string
12  stage: string
13  startedAt: string | null
14  verdict: string | null
15  confidence: number | null
16  confidenceRequested: number | null
17  score: number | null
18  escalate: boolean | null
19  summary: string | null
20  error: string | null
21  findingCount: number
22  checklist: { total: number; open: number; confirmed: number; rejected: number; unresolved: number } | null
23}
24
25export type Finding = {
26  rule_id: string
27  confirmed: boolean
28  severity: 'low' | 'medium' | 'high'
29  title: string
30  evidence: unknown[]
31}
32
33export type JobDetail = ScanRow & {
34  confidenceNote: string | null
35  categories: string[]
36  findings: Finding[]
37  unknowns: string[]
38  endpoints: string[]
39}
40
41export type DirEntry = { name: string; kind: 'dir' | 'apk' }
42
43export type Route =
44  | { page: 'start' }
45  | { page: 'jobs' }
46  | { page: 'device' }
47  | { page: 'library' }
48  | { page: 'job'; sha256: string; id: number }
49
50declare module 'claude-code' {
51  interface PluginState {
52    'droid-dna-ui': {
53      route: Route[]
54      results: LibraryRow[]
55      scans: ScanRow[]
56      job: JobDetail | null
57      dir: string
58      entries: DirEntry[]
59      busy: Record<string, string>
60      notice: string
61    }
62  }
63}
64