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

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.
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.
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.
To fetch known-malware samples, put a free abuse.ch key in ~/.droid-dna/.env (see .env.example). Keep it outside any repository.
~/.droid-dna/samples/. This repository never contains samples.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
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.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.hooks/register.tsx 410 lines1import { 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 <path to .apk> 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}
410types/index.d.ts 64 lines1export 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