AgentreBench commited on
Commit
ae8c826
·
verified ·
1 Parent(s): 051b5b6

Release xref-9b research preview

Browse files
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.gguf filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  *.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ pipeline_tag: text-generation
6
+ library_name: llama.cpp
7
+ tags:
8
+ - gguf
9
+ - qwen
10
+ - qwen3
11
+ - qwen3.5
12
+ - cybersecurity
13
+ - malware-analysis
14
+ - reverse-engineering
15
+ - pe
16
+ - elf
17
+ - ghidra
18
+ - agent
19
+ - research
20
+ inference: false
21
+ ---
22
+
23
+ <p align="center">
24
+ <img src="assets/xref-9b-banner.svg" alt="xref-9b" width="860">
25
+ </p>
26
+
27
+ # xref-9b
28
+
29
+ `xref-9b` is a research-preview reverse-engineering assistant for static PE and ELF malware triage. It is intended to be used with a local tool wrapper that exposes static analysis tools such as `file`, `strings`, `readelf`, `objdump`, PE header/import inspection, entropy checks, and optional Ghidra headless summaries.
30
+
31
+ This release is distributed as GGUF for local inference with llama.cpp.
32
+
33
+ ## What Is Included
34
+
35
+ | File | Purpose |
36
+ |---|---|
37
+ | `xref-9b-q4_k_m.gguf` | Recommended GGUF for local use. |
38
+ | `xref-9b-f16.gguf` | Full precision GGUF artifact for conversion/experimentation. |
39
+ | `agentre.py` | Interactive chat wrapper for PE/ELF reverse engineering. |
40
+ | `agentre_triage.py` | Static tool runtime and batch triage backend. |
41
+ | `reverse_engineering_spec_unified.md` | Tool-use and verdict spec given to the model. |
42
+ | `tools/ghidra_scripts/AgentRESummary.java` | Ghidra headless script used by `/ghidra` and `ghidra_summary`. |
43
+ | `scripts/` | Convenience scripts and environment template. |
44
+ | `assets/` | Release graphics and evaluation chart. |
45
+
46
+ ## Intended Use
47
+
48
+ This model is for defensive research, malware triage, reverse-engineering education, and local analyst-assist workflows. It is not a production malware detector and should not be used as the only basis for blocking, attribution, incident response, or legal decisions.
49
+
50
+ The model should be treated as an assistant that proposes evidence-backed hypotheses from static tool output. It can be wrong, especially on stripped, packed, obfuscated, or sparse-string binaries.
51
+
52
+ ## Training Summary
53
+
54
+ `xref-9b` was trained from a Qwen-family 9B base model using:
55
+
56
+ - supervised fine-tuning (SFT) on reverse-engineering instruction traces;
57
+ - additional PE and ELF malware/benign triage data;
58
+ - reinforcement learning / preference optimization with IPO over curated preference pairs;
59
+ - tool-use formatting for local static analysis workflows.
60
+
61
+ Training data sources are intentionally not enumerated in this public model card.
62
+
63
+ ## Evaluation Snapshot
64
+
65
+ Held-out mixed PE/ELF evaluation, 40 binaries total, balanced by format and benign/malicious label. Results are research-preview numbers and should be read as directional rather than definitive.
66
+
67
+ <p align="center">
68
+ <img src="assets/xref-9b-eval-chart.svg" alt="xref-9b evaluation chart" width="760">
69
+ </p>
70
+
71
+ | Checkpoint / workflow | Strict accuracy | Binary accuracy | Notes |
72
+ |---|---:|---:|---|
73
+ | SFT baseline | 55.0% | 62.5% | Initial supervised model. |
74
+ | SFT + IPO | 57.5% | 57.5% | Preference-tuned checkpoint; improved answer rate but still weak on ELF malware. |
75
+ | SFT + IPO + release static workflow | 67.5% | 67.5% | Same IPO family with deeper static-analysis workflow and final-answer forcing. |
76
+
77
+ Observed gains:
78
+
79
+ - strict accuracy improved from 55.0% to 67.5% in the release workflow (+12.5 percentage points);
80
+ - labeled ELF malware performance improved substantially under the deeper static workflow;
81
+ - benign ELF calibration regressed in that run, so unknown/benign decisions still need analyst review.
82
+
83
+ ## Quick Start
84
+
85
+ ### 1. Install Runtime Dependencies
86
+
87
+ Required:
88
+
89
+ - Python 3.10+
90
+ - llama.cpp with `llama-completion`
91
+ - common Unix tools: `file`, `strings`, `readelf`, `objdump`, `nm`, `hexdump` or `xxd`
92
+
93
+ Optional but recommended:
94
+
95
+ - Ghidra headless (`analyzeHeadless`)
96
+ - MinGW binutils for richer PE inspection on Linux (`x86_64-w64-mingw32-objdump`, `x86_64-w64-mingw32-nm`)
97
+
98
+ ### 2. Download
99
+
100
+ Example with Hugging Face CLI:
101
+
102
+ ```bash
103
+ hf download AgentreBench/xref-9b xref-9b-q4_k_m.gguf agentre.py agentre_triage.py reverse_engineering_spec_unified.md --local-dir xref-9b
104
+ ```
105
+
106
+ For full local package including scripts and Ghidra helper:
107
+
108
+ ```bash
109
+ hf download AgentreBench/xref-9b --local-dir xref-9b
110
+ ```
111
+
112
+ ### 3. Configure Environment
113
+
114
+ ```bash
115
+ cd xref-9b
116
+ cp scripts/xref9b.env.example scripts/xref9b.env
117
+ $EDITOR scripts/xref9b.env
118
+ source scripts/xref9b.env
119
+ ```
120
+
121
+ At minimum set:
122
+
123
+ ```bash
124
+ export XREF9B_GGUF="$PWD/xref-9b-q4_k_m.gguf"
125
+ export XREF9B_LLAMA_CLI="/path/to/llama.cpp/build/bin/llama-completion"
126
+ ```
127
+
128
+ Optional Ghidra:
129
+
130
+ ```bash
131
+ export GHIDRA_HEADLESS="/path/to/ghidra/support/analyzeHeadless"
132
+ ```
133
+
134
+ ### 4. Chat With One Binary
135
+
136
+ ```bash
137
+ python3 agentre.py chat /path/to/sample --model "$XREF9B_GGUF" --llama-cli "$XREF9B_LLAMA_CLI" --ctx-size 65536 --gpu-layers 99 --max-turns 8 --max-tool-calls 16 --max-tokens 1600 --obs-limit 8000
138
+ ```
139
+
140
+ Inside chat:
141
+
142
+ ```text
143
+ /deep run the deeper static workflow
144
+ /ghidra run Ghidra headless summary
145
+ /verdict force a concise final verdict
146
+ /compact compact long chat history
147
+ /tools list tools used
148
+ /save save transcript
149
+ /quit exit
150
+ ```
151
+
152
+ You can also ask naturally:
153
+
154
+ ```text
155
+ is this malicious or benign?
156
+ use ghidra and explain the key functions
157
+ is this packed?
158
+ what evidence supports malicious behavior?
159
+ ```
160
+
161
+ ### 5. Directory Mode
162
+
163
+ Directory mode is explicit:
164
+
165
+ ```bash
166
+ python3 agentre.py chat -d /path/to/binaries --model "$XREF9B_GGUF" --llama-cli "$XREF9B_LLAMA_CLI" --ctx-size 65536 --gpu-layers 99
167
+ ```
168
+
169
+ Use `/samples` and `/open N` inside the session.
170
+
171
+ ### 6. Batch Triage
172
+
173
+ ```bash
174
+ python3 agentre.py triage /path/to/binaries --model "$XREF9B_GGUF" --llama-cli "$XREF9B_LLAMA_CLI" --ctx-size 65536 --gpu-layers 99 --max-tool-calls 16 --max-tokens 1600 --obs-limit 8000
175
+ ```
176
+
177
+ ## Ghidra Support
178
+
179
+ `xref-9b` does not run arbitrary Ghidra commands. The wrapper exposes one Ghidra tool, `ghidra_summary`, implemented with Ghidra headless and `tools/ghidra_scripts/AgentRESummary.java`.
180
+
181
+ The wrapper runs a command equivalent to:
182
+
183
+ ```bash
184
+ analyzeHeadless /tmp/agentre_ghidra_xxx agentre_project -import <staged_sample> -overwrite -analysisTimeoutPerFile 180 -scriptPath tools/ghidra_scripts -postScript AgentRESummary.java /tmp/agentre_ghidra_summary.txt
185
+ ```
186
+
187
+ Ghidra is used for static summaries: program metadata, memory blocks, imports/external symbols, strings, functions, and short instruction excerpts. It does not unpack malware, execute binaries, emulate payloads, or dynamically dump memory.
188
+
189
+ ## Limitations
190
+
191
+ - Research preview; not production detection.
192
+ - Static-only; no execution, sandboxing, unpacking, or memory dumping.
193
+ - Stripped/static/packed binaries can require manual analyst follow-up.
194
+ - The model can overfit to tool artifacts or overclaim from weak signals.
195
+ - PE and ELF performance is uneven across families and compiler/linker settings.
196
+ - The model may classify offensive dual-use tools as `hackware` or `malicious` depending on evidence and workflow.
197
+ - Long tool histories can degrade answers; use `/compact`.
198
+
199
+ ## Improving The Model
200
+
201
+ Useful next steps for contributors:
202
+
203
+ - Add more stripped ELF and statically linked ELF training traces.
204
+ - Add calibrated benign PE examples that contain network/admin APIs but no malicious behavior.
205
+ - Add preference pairs that penalize overclaiming from entropy or strings alone.
206
+ - Add tool traces that distinguish virtual addresses from file offsets.
207
+ - Improve Ghidra summaries with function call graphs, xrefs, and decompiler snippets.
208
+ - Add safer structured verdict calibration: `malicious`, `benign`, `hackware`, `unknown`.
209
+ - Evaluate with family-disjoint and compiler-disjoint splits.
210
+
211
+ ## Recommended Settings
212
+
213
+ For most local analysis:
214
+
215
+ ```bash
216
+ --ctx-size 65536 --max-turns 8 --max-tool-calls 16 --max-tokens 1600 --obs-limit 8000
217
+ ```
218
+
219
+ Use larger `--max-tokens` for deeper explanations, and `/compact` if the session becomes repetitive or context-heavy.
220
+
221
+ ## File Hashes
222
+
223
+ ```text
224
+ 87f320a4edda407a00ddb005774640acac4c45882a9d03674e5f2c37e9dbafd6 xref-9b-q4_k_m.gguf
225
+ 2e9438786b7aed9a9314629e811900616c1b4666a2f7cffa2f537fd566f238d8 xref-9b-f16.gguf
226
+ ```
227
+
228
+ ## Citation
229
+
230
+ If you use this model in research, cite AgentRE-Bench / xref-9b and include the exact GGUF hash used.
SHA256SUMS ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ 87f320a4edda407a00ddb005774640acac4c45882a9d03674e5f2c37e9dbafd6 xref-9b-q4_k_m.gguf
2
+ 2e9438786b7aed9a9314629e811900616c1b4666a2f7cffa2f537fd566f238d8 xref-9b-f16.gguf
agentre.py ADDED
@@ -0,0 +1,983 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """AgentRE local launcher.
3
+
4
+ `chat` starts a persistent reverse-engineering session for one file, or a directory passed with `-d`.
5
+ `inspect` is kept as a compatibility alias for `chat`.
6
+ `triage` delegates to the existing bulk PE/ELF analyzer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import shlex
15
+ import shutil
16
+ import sys
17
+ import time
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import agentre_triage as triage
23
+
24
+
25
+ ROOT = Path(__file__).resolve().parent
26
+ LOCAL_AGENTRE_ENV = ROOT / "scripts" / "xref9b.env"
27
+
28
+
29
+ def read_shell_exports(path: Path) -> dict[str, str]:
30
+ exports: dict[str, str] = {}
31
+ if not path.exists():
32
+ return exports
33
+ for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
34
+ line = raw.strip()
35
+ if not line.startswith("export "):
36
+ continue
37
+ try:
38
+ parts = shlex.split(line)
39
+ except ValueError:
40
+ continue
41
+ for part in parts[1:]:
42
+ key, sep, value = part.partition("=")
43
+ if sep and key:
44
+ exports[key] = value
45
+ return exports
46
+
47
+
48
+ def load_local_agentre_env() -> dict[str, str]:
49
+ exports = read_shell_exports(LOCAL_AGENTRE_ENV)
50
+ for key, value in exports.items():
51
+ os.environ.setdefault(key, value)
52
+ return exports
53
+
54
+
55
+ def now_stamp() -> str:
56
+ return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
57
+
58
+
59
+ def print_top_help() -> None:
60
+ print(
61
+ """AgentRE local PE/ELF reverse-engineering CLI.
62
+
63
+ Usage:
64
+ python3 agentre.py chat FILE [options]
65
+ python3 agentre.py chat -d DIR [options]
66
+ python3 agentre.py inspect FILE [options]
67
+ python3 agentre.py inspect -d DIR [options]
68
+ python3 agentre.py triage FILE_OR_DIR [options]
69
+
70
+ Commands:
71
+ chat Chat with the model while it uses static PE/ELF RE tools. Use -d for directories.
72
+ inspect Alias for chat.
73
+ triage Analyze one file or a directory of PE/ELF files and write reports.
74
+
75
+ Examples:
76
+ python3 agentre.py chat holdout/mixed40/staged/sample_0003.elf
77
+ python3 agentre.py chat -d holdout/mixed40/staged
78
+ python3 agentre.py triage holdout/mixed40/staged --model ./xref-9b-q4_k_m.gguf
79
+ """
80
+ )
81
+
82
+
83
+ def build_backend(args: argparse.Namespace) -> tuple[str, Any]:
84
+ backend_name = args.backend
85
+ if backend_name == "auto":
86
+ backend_name = "llama-cpp" if str(args.model).lower().endswith(".gguf") else "transformers"
87
+ print(f"[xref 9b] backend={backend_name} model={args.model}", flush=True)
88
+ if args.adapter and backend_name == "transformers":
89
+ print(f"[xref 9b] adapter={args.adapter}", flush=True)
90
+ elif args.adapter and backend_name == "llama-cpp":
91
+ print("[xref 9b] note: --adapter is ignored by llama-cpp; use a merged GGUF or --llama-lora", flush=True)
92
+
93
+ if backend_name == "transformers":
94
+ return backend_name, triage.LocalQwenBackend(args.model, args.adapter or None, args.dtype, args.device_map)
95
+ return backend_name, triage.LlamaCppBackend(
96
+ args.model,
97
+ args.llama_cli,
98
+ args.ctx_size,
99
+ args.threads,
100
+ args.gpu_layers,
101
+ args.llama_lora or None,
102
+ args.llama_extra_arg,
103
+ )
104
+
105
+
106
+ def build_inspect_messages(spec: str, sample_id: str, fmt: str, request: str) -> list[dict[str, Any]]:
107
+ system = (
108
+ spec.strip()
109
+ + "\n\nRuntime rules:\n"
110
+ + f"- You are in an interactive reverse-engineering session for staged sample `{sample_id}` only.\n"
111
+ + f"- Detected format: {fmt}.\n"
112
+ + "- The original path is hidden from the model to avoid path/name bias.\n"
113
+ + "- Any tool path argument is ignored; tools are routed to the staged sample.\n"
114
+ + "- Use static analysis only. Never ask to execute the sample.\n"
115
+ + "- Use tools when they would materially improve the answer.\n"
116
+ + "- Answer the user's follow-up questions directly and cite concrete evidence from tool output.\n"
117
+ + "- If evidence is insufficient, say so plainly; classify as unknown or suspicious/unknown rather than guessing.\n"
118
+ + "- For stripped, static, packed, encrypted, or sparse-string samples where confidence is low, explicitly recommend closer disassembly with Ghidra or another disassembler.\n"
119
+ + "- Think internally, but do not expose chain-of-thought.\n"
120
+ + "- Only call final_answer when the user asks for a final verdict or when your answer is a classification decision.\n"
121
+ )
122
+ user = (
123
+ f"{request.strip()}\n\n"
124
+ "Start by inspecting basic metadata and strings, then give a concise initial assessment. "
125
+ "Do not require the user to know tool names."
126
+ )
127
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
128
+
129
+
130
+ def stage_one(source_path: Path, run_dir: Path, sample_id: str = "inspect_0001") -> tuple[Path, str]:
131
+ fmt = triage.detect_format(source_path)
132
+ suffix = ".elf" if fmt == "ELF" else ".exe" if fmt == "PE" else source_path.suffix
133
+ stage_dir = run_dir / "staged"
134
+ stage_dir.mkdir(parents=True, exist_ok=True)
135
+ staged_path = (stage_dir / f"{sample_id}{suffix}").resolve()
136
+ shutil.copy2(source_path, staged_path)
137
+ return staged_path, fmt
138
+
139
+
140
+ def progress(state: dict[str, Any], message: str) -> None:
141
+ if state.get("progress", True):
142
+ print(f"[xref 9b] {message}", file=sys.stderr, flush=True)
143
+
144
+
145
+ def looks_truncated_reply(text: str) -> bool:
146
+ stripped = text.rstrip()
147
+ if len(stripped) < 600:
148
+ return False
149
+ if stripped.endswith((".", "!", "?", ")", "]", "}", "`")):
150
+ return False
151
+ tail = stripped.rsplit(maxsplit=1)[-1].lower().strip(",:;")
152
+ return tail in {
153
+ "a",
154
+ "an",
155
+ "and",
156
+ "are",
157
+ "as",
158
+ "because",
159
+ "but",
160
+ "for",
161
+ "from",
162
+ "given",
163
+ "in",
164
+ "is",
165
+ "of",
166
+ "or",
167
+ "that",
168
+ "the",
169
+ "this",
170
+ "to",
171
+ "with",
172
+ } or not stripped.endswith((".", "!", "?", ":", ";"))
173
+
174
+
175
+ def requested_direct_tool(text: str) -> tuple[str, dict[str, Any]] | None:
176
+ lowered = text.lower()
177
+ if "ghidra" in lowered and any(
178
+ word in lowered
179
+ for word in ("analyze", "disassembl", "dive", "open", "run", "try", "use", "with")
180
+ ):
181
+ return "ghidra_summary", {"timeout": 180}
182
+ return None
183
+
184
+
185
+ def format_final(answer: dict[str, Any] | None) -> str:
186
+ if not answer:
187
+ return "Final verdict was submitted, but it was not valid JSON."
188
+ label = triage.normalize_prediction(answer)
189
+ summary = answer.get("summary") or answer.get("rationale") or answer.get("reason") or ""
190
+ evidence = answer.get("evidence") or answer.get("key_evidence") or []
191
+ lines = [f"Final verdict: {label}"]
192
+ if summary:
193
+ lines.append(str(summary))
194
+ if isinstance(evidence, list) and evidence:
195
+ lines.append("Evidence:")
196
+ for item in evidence[:6]:
197
+ lines.append(f"- {item}")
198
+ return "\n".join(lines)
199
+
200
+
201
+ def shorten_text(text: str, limit: int) -> str:
202
+ stripped = "\n".join(line.rstrip() for line in text.strip().splitlines())
203
+ if len(stripped) <= limit:
204
+ return stripped
205
+ return stripped[:limit].rstrip() + f"\n[truncated to {limit} chars]"
206
+
207
+
208
+ def deep_tool_sequence(fmt: str) -> list[tuple[str, dict[str, Any]]]:
209
+ common: list[tuple[str, dict[str, Any]]] = [
210
+ ("file", {}),
211
+ ("entropy", {}),
212
+ ("strings", {"min_length": 5}),
213
+ ]
214
+ if fmt == "ELF":
215
+ return [
216
+ ("file", {}),
217
+ ("readelf", {}),
218
+ ("nm", {}),
219
+ ("entropy", {}),
220
+ ("strings", {"min_length": 5}),
221
+ ("objdump", {}),
222
+ ("ghidra_summary", {"timeout": 240}),
223
+ ]
224
+ if fmt == "PE":
225
+ return [
226
+ ("file", {}),
227
+ ("pe_headers", {}),
228
+ ("pe_sections", {}),
229
+ ("pe_imports", {}),
230
+ ("pe_exports", {}),
231
+ ("entropy", {}),
232
+ ("strings", {"min_length": 5}),
233
+ ("pe_disasm", {}),
234
+ ("ghidra_summary", {"timeout": 240}),
235
+ ]
236
+ return common + [("ghidra_summary", {"timeout": 240})]
237
+
238
+
239
+ def compact_messages(
240
+ messages: list[dict[str, Any]],
241
+ tools: triage.StaticTools,
242
+ state: dict[str, Any],
243
+ max_tool_summaries: int = 12,
244
+ max_assistant_summaries: int = 5,
245
+ ) -> list[dict[str, Any]]:
246
+ system_msg = next((msg for msg in messages if msg.get("role") == "system"), None)
247
+ first_user = next((msg for msg in messages if msg.get("role") == "user"), None)
248
+
249
+ tool_msgs = [msg for msg in messages if msg.get("role") == "tool"][-max_tool_summaries:]
250
+ assistant_msgs = [
251
+ msg for msg in messages
252
+ if msg.get("role") == "assistant" and str(msg.get("content") or "").strip()
253
+ ][-max_assistant_summaries:]
254
+
255
+ lines = [
256
+ "Session context was compacted to reduce prompt length.",
257
+ f"Total tool calls before compaction: {state.get('tool_calls_seen', 0)}",
258
+ "Tools used: " + (", ".join(tools.used_tools[-40:]) if tools.used_tools else "none"),
259
+ ]
260
+ if state.get("last_final"):
261
+ lines.append("Last submitted final verdict JSON: " + json.dumps(state["last_final"], ensure_ascii=True)[:1600])
262
+
263
+ if tool_msgs:
264
+ lines.append("\nRecent tool observations:")
265
+ for msg in tool_msgs:
266
+ name = str(msg.get("name") or "tool")
267
+ content = shorten_text(str(msg.get("content") or ""), 900)
268
+ lines.append(f"\n[{name}]\n{content}")
269
+
270
+ if assistant_msgs:
271
+ lines.append("\nRecent assistant conclusions:")
272
+ for msg in assistant_msgs:
273
+ content = shorten_text(str(msg.get("content") or ""), 700)
274
+ lines.append(f"- {content}")
275
+
276
+ lines.append("\nIf exact old bytes or full tool output are needed, rerun a targeted tool or /deep.")
277
+
278
+ compacted: list[dict[str, Any]] = []
279
+ if system_msg:
280
+ compacted.append(system_msg)
281
+ if first_user:
282
+ compacted.append(first_user)
283
+ compacted.append({"role": "assistant", "content": "\n".join(lines), "reasoning_content": ""})
284
+ return compacted
285
+
286
+
287
+ def save_compaction_snapshot(run_dir: Path, session: dict[str, Any]) -> Path:
288
+ snapshot_dir = run_dir / "compaction_snapshots"
289
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
290
+ sample_id = str(session.get("sample_id") or "sample")
291
+ path = snapshot_dir / f"{sample_id}_{now_stamp()}_precompact.json"
292
+ payload = {
293
+ "sample_id": sample_id,
294
+ "source_path": str(session.get("source_path")),
295
+ "staged_path": str(session.get("staged_path")),
296
+ "format": session.get("fmt"),
297
+ "tools_used": session.get("tools").used_tools if session.get("tools") else [],
298
+ "state": session.get("state", {}),
299
+ "messages": session.get("messages", []),
300
+ "saved_at": datetime.now(timezone.utc).isoformat(),
301
+ }
302
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
303
+ return path
304
+
305
+
306
+ def append_direct_tool_result(
307
+ canonical: str,
308
+ args: dict[str, Any],
309
+ tools: triage.StaticTools,
310
+ messages: list[dict[str, Any]],
311
+ cfg: triage.RuntimeConfig,
312
+ state: dict[str, Any],
313
+ ) -> None:
314
+ call_id = f"manual_{int(time.time() * 1000)}"
315
+ started = time.monotonic()
316
+ state["tool_calls_seen"] = int(state.get("tool_calls_seen", 0)) + 1
317
+ progress(state, f"direct tool {state['tool_calls_seen']}: {canonical}")
318
+ try:
319
+ content = getattr(tools, canonical)(**args)
320
+ except Exception as exc:
321
+ content = f"tool error: {type(exc).__name__}: {exc}"
322
+ elapsed = time.monotonic() - started
323
+ progress(state, f"tool done: {canonical} in {elapsed:.1f}s, observation={len(str(content))} chars")
324
+
325
+ messages.append(
326
+ {
327
+ "role": "assistant",
328
+ "content": "",
329
+ "reasoning_content": "",
330
+ "tool_calls": [
331
+ {
332
+ "id": call_id,
333
+ "type": "function",
334
+ "function": {"name": canonical, "arguments": args},
335
+ }
336
+ ],
337
+ }
338
+ )
339
+ messages.append(
340
+ {
341
+ "role": "tool",
342
+ "tool_call_id": call_id,
343
+ "name": canonical,
344
+ "content": str(content)[: cfg.obs_limit],
345
+ }
346
+ )
347
+
348
+
349
+ def save_inspect_session(
350
+ run_dir: Path,
351
+ source_path: Path,
352
+ staged_path: Path,
353
+ fmt: str,
354
+ messages: list[dict[str, Any]],
355
+ tools: triage.StaticTools,
356
+ state: dict[str, Any],
357
+ ) -> None:
358
+ sample_id = str(state.get("sample_id") or staged_path.stem)
359
+ payload = {
360
+ "sample_id": sample_id,
361
+ "source_path": str(source_path),
362
+ "staged_path": str(staged_path),
363
+ "format": fmt,
364
+ "tools_used": tools.used_tools,
365
+ "tool_calls": state.get("tool_calls_seen", 0),
366
+ "last_final": state.get("last_final"),
367
+ "messages": messages,
368
+ "saved_at": datetime.now(timezone.utc).isoformat(),
369
+ }
370
+ json_text = json.dumps(payload, indent=2, ensure_ascii=True) + "\n"
371
+ (run_dir / "inspect_transcript.json").write_text(json_text, encoding="utf-8")
372
+ (run_dir / f"{sample_id}_transcript.json").write_text(json_text, encoding="utf-8")
373
+
374
+ lines = [
375
+ "# AgentRE Chat Session",
376
+ "",
377
+ f"- Sample: `{sample_id}`",
378
+ f"- Source: `{source_path}`",
379
+ f"- Staged: `{staged_path}`",
380
+ f"- Format: `{fmt}`",
381
+ f"- Tool calls: `{state.get('tool_calls_seen', 0)}`",
382
+ "",
383
+ "## Conversation",
384
+ "",
385
+ ]
386
+ for msg in messages:
387
+ role = msg.get("role")
388
+ if role not in {"user", "assistant"}:
389
+ continue
390
+ content = str(msg.get("content") or "").strip()
391
+ if not content:
392
+ continue
393
+ lines.append(f"### {role}")
394
+ lines.append("")
395
+ lines.append(content[:8000])
396
+ lines.append("")
397
+ md_text = "\n".join(lines)
398
+ (run_dir / "INSPECT.md").write_text(md_text, encoding="utf-8")
399
+ (run_dir / f"{sample_id}.md").write_text(md_text, encoding="utf-8")
400
+
401
+
402
+ def run_tool_calls(
403
+ tool_calls: list[dict[str, Any]],
404
+ tools: triage.StaticTools,
405
+ messages: list[dict[str, Any]],
406
+ cfg: triage.RuntimeConfig,
407
+ state: dict[str, Any],
408
+ turn: int,
409
+ ) -> str | None:
410
+ final_text: str | None = None
411
+ for idx, tc in enumerate(tool_calls):
412
+ fn = tc.get("function", {})
413
+ name = str(fn.get("name") or "")
414
+ args = triage._parse_args(fn.get("arguments"))
415
+ tcid = tc.get("id") or f"inspect_{turn}_{idx}"
416
+ started = time.monotonic()
417
+
418
+ if name in triage.FINAL_TOOLS:
419
+ progress(state, "model submitted final_answer")
420
+ answer = triage.final_answer_from_args(args)
421
+ state["last_final"] = answer
422
+ content = "final verdict submitted"
423
+ final_text = format_final(answer)
424
+ else:
425
+ canonical = triage.TOOL_ALIASES.get(name)
426
+ if canonical is None:
427
+ progress(state, f"unknown tool requested: {name}")
428
+ content = f"unknown tool {name}. Use listed tools or answer naturally."
429
+ elif int(state.get("request_tool_calls_seen", 0)) >= cfg.max_tool_calls:
430
+ progress(state, "tool budget exhausted for this request")
431
+ content = "tool budget exhausted for this request; answer with the evidence already gathered, or ask the user to continue."
432
+ else:
433
+ state["request_tool_calls_seen"] = int(state.get("request_tool_calls_seen", 0)) + 1
434
+ state["tool_calls_seen"] += 1
435
+ progress(
436
+ state,
437
+ f"tool {state['request_tool_calls_seen']}/{cfg.max_tool_calls} this request "
438
+ f"(total {state['tool_calls_seen']}): {canonical}",
439
+ )
440
+ try:
441
+ content = getattr(tools, canonical)(**args)
442
+ except Exception as exc:
443
+ content = f"tool error: {type(exc).__name__}: {exc}"
444
+ elapsed = time.monotonic() - started
445
+ progress(state, f"tool done: {canonical} in {elapsed:.1f}s, observation={len(str(content))} chars")
446
+
447
+ messages.append(
448
+ {
449
+ "role": "tool",
450
+ "tool_call_id": tcid,
451
+ "name": name,
452
+ "content": str(content)[: cfg.obs_limit],
453
+ }
454
+ )
455
+ return final_text
456
+
457
+
458
+ def generate_visible_reply(
459
+ backend: Any,
460
+ messages: list[dict[str, Any]],
461
+ cfg: triage.RuntimeConfig,
462
+ tools: triage.StaticTools,
463
+ state: dict[str, Any],
464
+ ) -> str:
465
+ final_text: str | None = None
466
+ last_visible_reply = ""
467
+ own_tool_budget = "request_tool_calls_seen" not in state
468
+ if own_tool_budget:
469
+ state["request_tool_calls_seen"] = 0
470
+ for turn in range(cfg.max_turns):
471
+ progress(state, f"model turn {turn + 1}/{cfg.max_turns}: generating")
472
+ started = time.monotonic()
473
+ generated, step_usage = backend.generate(
474
+ messages,
475
+ triage.tool_definitions(),
476
+ cfg.max_tokens,
477
+ cfg.temperature,
478
+ cfg.thinking,
479
+ )
480
+ elapsed = time.monotonic() - started
481
+ progress(state, f"model turn {turn + 1}/{cfg.max_turns}: generated in {elapsed:.1f}s")
482
+ usage = state.setdefault("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
483
+ for key, value in step_usage.items():
484
+ usage[key] = usage.get(key, 0) + int(value)
485
+
486
+ reasoning, visible = triage.split_thinking(generated)
487
+ tool_calls = triage.parse_tool_calls(generated)
488
+ if tool_calls:
489
+ progress(state, f"model requested {len(tool_calls)} tool call(s)")
490
+ assistant_content = triage.strip_tool_xml(visible)
491
+ if assistant_content:
492
+ last_visible_reply = assistant_content
493
+ assistant_msg: dict[str, Any] = {"role": "assistant", "content": assistant_content}
494
+ if reasoning and cfg.save_reasoning:
495
+ assistant_msg["reasoning_content"] = reasoning
496
+ elif reasoning:
497
+ assistant_msg["reasoning_content"] = ""
498
+ if tool_calls:
499
+ assistant_msg["tool_calls"] = [
500
+ triage.make_history_tool_call(tc, f"inspect_{turn}_{idx}")
501
+ for idx, tc in enumerate(tool_calls)
502
+ ]
503
+ messages.append(assistant_msg)
504
+
505
+ if not tool_calls:
506
+ reply = assistant_content or generated.strip() or "(no response)"
507
+ if looks_truncated_reply(reply):
508
+ state["last_reply_truncated"] = True
509
+ progress(state, "reply may have hit --max-tokens; use /continue or raise --max-tokens")
510
+ reply += (
511
+ "\n\n[xref 9b] response may have hit --max-tokens before finishing; "
512
+ "use /continue or raise --max-tokens for longer answers."
513
+ )
514
+ if own_tool_budget:
515
+ state.pop("request_tool_calls_seen", None)
516
+ return reply
517
+
518
+ maybe_final = run_tool_calls(tool_calls, tools, messages, cfg, state, turn)
519
+ if maybe_final:
520
+ final_text = maybe_final
521
+ messages.append(
522
+ {
523
+ "role": "user",
524
+ "content": "Summarize that final verdict for the user in concise natural language. Do not call another tool unless needed.",
525
+ }
526
+ )
527
+
528
+ if final_text:
529
+ if own_tool_budget:
530
+ state.pop("request_tool_calls_seen", None)
531
+ return final_text
532
+ if last_visible_reply:
533
+ if own_tool_budget:
534
+ state.pop("request_tool_calls_seen", None)
535
+ return (
536
+ last_visible_reply
537
+ + "\n\n[xref 9b] internal tool loop reached --max-turns before a final tool submission; "
538
+ + "ask a follow-up or raise --max-turns if you want deeper analysis."
539
+ )
540
+ if own_tool_budget:
541
+ state.pop("request_tool_calls_seen", None)
542
+ return "[xref 9b] assistant turn stopped after the internal turn limit; ask a narrower follow-up or raise --max-turns."
543
+
544
+
545
+ def default_chat_model() -> str:
546
+ packaged = ROOT / "xref-9b-q4_k_m.gguf"
547
+ return (
548
+ os.environ.get("XREF9B_GGUF")
549
+ or os.environ.get("AGENTRE_GGUF")
550
+ or os.environ.get("AGENTRE_MODEL")
551
+ or (str(packaged) if packaged.exists() else triage.DEFAULT_BASE_MODEL)
552
+ )
553
+
554
+
555
+ def default_llama_cli() -> str:
556
+ return os.environ.get("XREF9B_LLAMA_CLI") or os.environ.get("AGENTRE_LLAMA_CLI") or triage.DEFAULT_LLAMA_CLI
557
+
558
+
559
+ def default_adapter_for_model(model: str) -> str:
560
+ return "" if str(model).lower().endswith(".gguf") else triage.DEFAULT_ADAPTER
561
+
562
+
563
+ def sample_id_for_index(index: int, total: int) -> str:
564
+ return "inspect_0001" if total == 1 else f"sample_{index + 1:04d}"
565
+
566
+
567
+ def display_path(path: Path, root: Path) -> str:
568
+ try:
569
+ base = root if root.is_dir() else root.parent
570
+ return str(path.relative_to(base))
571
+ except Exception:
572
+ return str(path)
573
+
574
+
575
+ def save_chat_index(
576
+ run_dir: Path,
577
+ target_root: Path,
578
+ targets: list[Path],
579
+ active_idx: int,
580
+ sessions: dict[int, dict[str, Any]],
581
+ ) -> None:
582
+ samples: list[dict[str, Any]] = []
583
+ for idx, path in enumerate(targets):
584
+ session = sessions.get(idx)
585
+ state = session.get("state", {}) if session else {}
586
+ samples.append(
587
+ {
588
+ "index": idx + 1,
589
+ "sample_id": sample_id_for_index(idx, len(targets)),
590
+ "path": str(path),
591
+ "display_path": display_path(path, target_root),
592
+ "format": session.get("fmt") if session else triage.detect_format(path),
593
+ "opened": bool(session),
594
+ "tool_calls": state.get("tool_calls_seen", 0),
595
+ "last_final": state.get("last_final"),
596
+ }
597
+ )
598
+ payload = {
599
+ "target_root": str(target_root),
600
+ "active_index": active_idx + 1,
601
+ "samples": samples,
602
+ "saved_at": datetime.now(timezone.utc).isoformat(),
603
+ }
604
+ (run_dir / "chat_state.json").write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
605
+
606
+ lines = [
607
+ "# AgentRE Chat Index",
608
+ "",
609
+ f"- Target: `{target_root}`",
610
+ f"- Active sample: `{active_idx + 1}`",
611
+ f"- Samples: `{len(targets)}`",
612
+ "",
613
+ "## Samples",
614
+ "",
615
+ ]
616
+ for sample in samples:
617
+ marker = "*" if sample["index"] == active_idx + 1 else "-"
618
+ opened = " opened" if sample["opened"] else ""
619
+ lines.append(
620
+ f"{marker} `{sample['index']:04d}` `{sample['format']}`{opened} "
621
+ f"tools={sample['tool_calls']} `{sample['display_path']}`"
622
+ )
623
+ (run_dir / "CHAT.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
624
+
625
+
626
+ def print_sample_list(target_root: Path, targets: list[Path], active_idx: int, sessions: dict[int, dict[str, Any]], limit: int = 80) -> None:
627
+ shown = targets[:limit]
628
+ for idx, path in enumerate(shown):
629
+ session = sessions.get(idx)
630
+ fmt = session.get("fmt") if session else triage.detect_format(path)
631
+ marker = "*" if idx == active_idx else " "
632
+ opened = " opened" if session else ""
633
+ print(f"{marker} {idx + 1:4d} {fmt:7s}{opened:8s} {display_path(path, target_root)}")
634
+ if len(targets) > limit:
635
+ print(f"... {len(targets) - limit} more samples not shown; use /open N by index.")
636
+
637
+
638
+ def resolve_sample_selector(selector: str, target_root: Path, targets: list[Path]) -> int:
639
+ text = selector.strip()
640
+ if not text:
641
+ raise ValueError("usage: /open N or /open path-substring")
642
+ if text.isdigit():
643
+ idx = int(text) - 1
644
+ if 0 <= idx < len(targets):
645
+ return idx
646
+ raise ValueError(f"sample index out of range: {text}")
647
+
648
+ maybe_path = Path(text).expanduser()
649
+ if maybe_path.exists():
650
+ resolved = maybe_path.resolve()
651
+ for idx, path in enumerate(targets):
652
+ if path == resolved:
653
+ return idx
654
+
655
+ lowered = text.lower()
656
+ matches = [
657
+ idx
658
+ for idx, path in enumerate(targets)
659
+ if lowered in path.name.lower() or lowered in display_path(path, target_root).lower() or lowered in str(path).lower()
660
+ ]
661
+ if len(matches) == 1:
662
+ return matches[0]
663
+ if not matches:
664
+ raise ValueError(f"no sample matched: {text}")
665
+ preview = ", ".join(str(idx + 1) for idx in matches[:12])
666
+ raise ValueError(f"ambiguous sample selector; matched indexes: {preview}")
667
+
668
+
669
+ def run_tool_smoke_targets(targets: list[Path], run_dir: Path, cfg: triage.RuntimeConfig) -> int:
670
+ for idx, source_path in enumerate(targets):
671
+ sample_id = sample_id_for_index(idx, len(targets))
672
+ staged_path, fmt = stage_one(source_path, run_dir, sample_id)
673
+ tools = triage.StaticTools(staged_path, fmt, cfg.obs_limit, cfg.ghidra_script_dir)
674
+ print(f"== {idx + 1}: {source_path} ({fmt}) ==")
675
+ print("\n[file]")
676
+ print(tools.file())
677
+ print("\n[strings]")
678
+ print(tools.strings()[:2500])
679
+ print(f"\n[xref 9b] staged={staged_path}")
680
+ return 0
681
+
682
+
683
+ def run_chat(argv: list[str], command_name: str = "chat") -> int:
684
+ parser = argparse.ArgumentParser(
685
+ prog=f"agentre.py {command_name}",
686
+ description="Chat with AgentRE about one PE/ELF sample, or a directory passed with -d, while it uses static RE tools.",
687
+ )
688
+ loaded_env = load_local_agentre_env()
689
+ default_model = default_chat_model()
690
+ default_cli = default_llama_cli()
691
+ parser.add_argument("target", nargs="?", help="Binary file to inspect. Use -d/--directory for directory mode.")
692
+ parser.add_argument("-d", "--directory", default="", help="Directory of PE/ELF files to inspect interactively.")
693
+ parser.add_argument("--spec", default=str(triage.DEFAULT_SPEC), help="Unified reverse-engineering spec.")
694
+ parser.add_argument(
695
+ "--backend",
696
+ choices=["auto", "transformers", "llama-cpp"],
697
+ default="auto",
698
+ help="Inference backend. auto selects llama-cpp for .gguf models, otherwise transformers.",
699
+ )
700
+ parser.add_argument("--model", default=default_model, help="HF base model path/name or GGUF path for llama.cpp.")
701
+ parser.add_argument("--adapter", default=None, help="Optional HF LoRA adapter for transformers. Use '' to disable.")
702
+ parser.add_argument("--output-dir", default="", help="Output directory. Defaults to runs/agentre_chat_<timestamp>.")
703
+ parser.add_argument("--max-files", type=int, default=0, help="Directory mode limit; 0 means no limit.")
704
+ parser.add_argument("--include-unknown", action="store_true", help="Include files that are not PE/ELF by magic bytes.")
705
+ parser.add_argument("--max-turns", type=int, default=8, help="Internal assistant/tool rounds per user message.")
706
+ parser.add_argument("--max-tool-calls", type=int, default=40, help="Model-selected tool calls allowed per user request.")
707
+ parser.add_argument("--max-tokens", type=int, default=1200)
708
+ parser.add_argument("--obs-limit", type=int, default=7000)
709
+ parser.add_argument("--temperature", type=float, default=0.1)
710
+ parser.add_argument("--dtype", default="auto", help="auto, bfloat16, float16, float32.")
711
+ parser.add_argument("--device-map", default="auto")
712
+ parser.add_argument("--llama-cli", default=default_cli, help="Path to llama.cpp llama-completion.")
713
+ parser.add_argument("--ctx-size", type=int, default=65536, help="llama.cpp context size.")
714
+ parser.add_argument("--threads", type=int, default=0, help="llama.cpp CPU threads; 0 lets llama.cpp choose.")
715
+ parser.add_argument("--gpu-layers", type=int, default=-1, help="llama.cpp GPU layers; -1 leaves default.")
716
+ parser.add_argument("--llama-lora", default="", help="Optional llama.cpp-compatible LoRA adapter.")
717
+ parser.add_argument("--llama-extra-arg", action="append", default=[], help="Extra raw argument passed to llama-completion. Repeatable.")
718
+ parser.add_argument("--no-thinking", action="store_true", help="Disable Qwen thinking template flag.")
719
+ parser.add_argument("--quiet", action="store_true", help="Hide progress messages while the model/tools run.")
720
+ parser.add_argument("--save-reasoning", action="store_true", help=argparse.SUPPRESS)
721
+ parser.add_argument("--ghidra-script-dir", default=str(triage.DEFAULT_GHIDRA_SCRIPT_DIR))
722
+ parser.add_argument(
723
+ "--request",
724
+ default="Is this malicious or benign? Reverse engineer it and use the available tools.",
725
+ help="Initial user request.",
726
+ )
727
+ parser.add_argument("--one-shot", action="store_true", help="Run the initial inspection and exit instead of opening the prompt.")
728
+ parser.add_argument("--tool-smoke", action="store_true", help="Run static tools and exit without loading a model.")
729
+ args = parser.parse_args(argv)
730
+
731
+ if loaded_env and not args.quiet:
732
+ print(f"[xref 9b] loaded defaults from {LOCAL_AGENTRE_ENV}", flush=True)
733
+
734
+ args.adapter = default_adapter_for_model(args.model) if args.adapter is None else args.adapter
735
+
736
+ if args.directory and args.target:
737
+ print("[xref 9b] provide either one file target or -d/--directory, not both.", file=sys.stderr)
738
+ return 2
739
+ if not args.directory and not args.target:
740
+ print("[xref 9b] provide one binary file, or use -d/--directory DIR for directory mode.", file=sys.stderr)
741
+ return 2
742
+
743
+ directory_mode = bool(args.directory)
744
+ target_root = Path(args.directory or args.target).expanduser().resolve()
745
+ if directory_mode:
746
+ if not target_root.is_dir():
747
+ print(f"[xref 9b] -d/--directory expects a directory: {target_root}", file=sys.stderr)
748
+ return 2
749
+ elif target_root.is_dir():
750
+ print(f"[xref 9b] refusing implicit directory chat target: {target_root}", file=sys.stderr)
751
+ print("[xref 9b] use -d/--directory for directory mode, or pass the full file path.", file=sys.stderr)
752
+ return 2
753
+
754
+ try:
755
+ targets = triage.collect_targets(target_root, include_unknown=args.include_unknown, max_files=args.max_files)
756
+ except FileNotFoundError:
757
+ print(f"[xref 9b] target not found: {target_root}", file=sys.stderr)
758
+ return 2
759
+ if not targets:
760
+ print(f"[xref 9b] no PE/ELF targets found under {target_root}", file=sys.stderr)
761
+ return 2
762
+
763
+ run_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else (ROOT / "runs" / f"agentre_chat_{now_stamp()}").resolve()
764
+ run_dir.mkdir(parents=True, exist_ok=True)
765
+ cfg = triage.RuntimeConfig(
766
+ max_turns=args.max_turns,
767
+ max_tool_calls=args.max_tool_calls,
768
+ max_tokens=args.max_tokens,
769
+ obs_limit=args.obs_limit,
770
+ temperature=args.temperature,
771
+ thinking=not args.no_thinking,
772
+ save_transcripts=True,
773
+ save_reasoning=False,
774
+ ghidra_script_dir=Path(args.ghidra_script_dir).expanduser().resolve(),
775
+ )
776
+
777
+ if args.tool_smoke:
778
+ return run_tool_smoke_targets(targets, run_dir, cfg)
779
+
780
+ spec = Path(args.spec).read_text(encoding="utf-8")
781
+ sessions: dict[int, dict[str, Any]] = {}
782
+ active_idx = 0
783
+
784
+ print(f"[xref 9b] chat target={target_root}", flush=True)
785
+ print(f"[xref 9b] discovered={len(targets)} output={run_dir}", flush=True)
786
+ print(f"[xref 9b] thinking={'on' if cfg.thinking else 'off'}", flush=True)
787
+ if len(targets) > 1:
788
+ print("[xref 9b] use /samples to list files and /open N to switch samples", flush=True)
789
+ _, backend = build_backend(args)
790
+
791
+ def ensure_session(index: int) -> dict[str, Any]:
792
+ if index in sessions:
793
+ return sessions[index]
794
+ source_path = targets[index]
795
+ sample_id = sample_id_for_index(index, len(targets))
796
+ staged_path, fmt = stage_one(source_path, run_dir, sample_id)
797
+ session = {
798
+ "source_path": source_path,
799
+ "sample_id": sample_id,
800
+ "staged_path": staged_path,
801
+ "fmt": fmt,
802
+ "tools": triage.StaticTools(staged_path, fmt, cfg.obs_limit, cfg.ghidra_script_dir),
803
+ "messages": build_inspect_messages(spec, sample_id, fmt, args.request),
804
+ "state": {"sample_id": sample_id, "tool_calls_seen": 0, "last_final": None, "progress": not args.quiet},
805
+ "started": False,
806
+ }
807
+ sessions[index] = session
808
+ return session
809
+
810
+ def save_session(index: int) -> None:
811
+ session = ensure_session(index)
812
+ save_inspect_session(
813
+ run_dir,
814
+ session["source_path"],
815
+ session["staged_path"],
816
+ session["fmt"],
817
+ session["messages"],
818
+ session["tools"],
819
+ session["state"],
820
+ )
821
+ save_chat_index(run_dir, target_root, targets, index, sessions)
822
+
823
+ def activate_sample(index: int) -> dict[str, Any]:
824
+ session = ensure_session(index)
825
+ print(
826
+ f"[xref 9b] active {index + 1}/{len(targets)} {session['sample_id']} "
827
+ f"format={session['fmt']} path={display_path(session['source_path'], target_root)}",
828
+ flush=True,
829
+ )
830
+ if not session["started"]:
831
+ reply = generate_visible_reply(backend, session["messages"], cfg, session["tools"], session["state"])
832
+ session["started"] = True
833
+ print("\nxref 9b> " + reply.strip() + "\n")
834
+ save_session(index)
835
+ return session
836
+
837
+ current = activate_sample(active_idx)
838
+ if args.one_shot:
839
+ print(f"[xref 9b] transcript={run_dir / 'INSPECT.md'}")
840
+ return 0
841
+
842
+ print("Commands: /help, /samples, /open N, /current, /tools, /deep, /ghidra, /verdict, /compact, /continue, /save, /quit")
843
+ while True:
844
+ try:
845
+ user_text = input("you> ").strip()
846
+ except (EOFError, KeyboardInterrupt):
847
+ print()
848
+ break
849
+ if not user_text:
850
+ continue
851
+ if user_text in {"/q", "/quit", "quit", "exit"}:
852
+ break
853
+ if user_text == "/help":
854
+ print("Ask natural-language RE questions about the active sample.")
855
+ print("Examples: `is this packed?`, `run Ghidra`, `why malicious?`, `what strings matter?`")
856
+ print("Slash commands: /samples, /open N, /current, /tools, /deep, /ghidra, /verdict, /compact, /continue, /save, /quit.")
857
+ continue
858
+ if user_text == "/samples":
859
+ print_sample_list(target_root, targets, active_idx, sessions)
860
+ continue
861
+ if user_text.startswith("/open"):
862
+ selector = user_text[len("/open") :].strip()
863
+ try:
864
+ active_idx = resolve_sample_selector(selector, target_root, targets)
865
+ except ValueError as exc:
866
+ print(f"[xref 9b] {exc}")
867
+ continue
868
+ current = activate_sample(active_idx)
869
+ continue
870
+ if user_text == "/current":
871
+ state = current["state"]
872
+ print(
873
+ f"{active_idx + 1}/{len(targets)} {current['sample_id']} {current['fmt']} "
874
+ f"tools={state.get('tool_calls_seen', 0)} {display_path(current['source_path'], target_root)}"
875
+ )
876
+ continue
877
+ if user_text == "/tools":
878
+ used = current["tools"].used_tools
879
+ print(", ".join(used) if used else "(no tools used yet)")
880
+ continue
881
+ if user_text == "/save":
882
+ save_session(active_idx)
883
+ print(f"[xref 9b] transcript={run_dir / 'INSPECT.md'}")
884
+ continue
885
+ if user_text == "/compact":
886
+ before = len(current["messages"])
887
+ snapshot = save_compaction_snapshot(run_dir, current)
888
+ current["messages"] = compact_messages(current["messages"], current["tools"], current["state"])
889
+ after = len(current["messages"])
890
+ save_session(active_idx)
891
+ print(f"[xref 9b] compacted conversation {before}->{after} messages; precompact={snapshot}")
892
+ continue
893
+ if user_text == "/continue":
894
+ user_text = "Continue the previous answer from where it stopped. Do not repeat earlier content."
895
+ elif user_text == "/ghidra":
896
+ user_text = "Run Ghidra headless on this sample and explain what it shows."
897
+
898
+ if user_text == "/deep":
899
+ state = current["state"]
900
+ current["messages"].append({
901
+ "role": "user",
902
+ "content": "Run the deeper static reverse-engineering workflow for this sample, then summarize the evidence.",
903
+ })
904
+ sequence = deep_tool_sequence(current["fmt"])
905
+ progress(state, f"deep analysis: running {len(sequence)} static tools")
906
+ for canonical, tool_args in sequence:
907
+ append_direct_tool_result(canonical, tool_args, current["tools"], current["messages"], cfg, state)
908
+ current["messages"].append({
909
+ "role": "user",
910
+ "content": (
911
+ "Using the deep static tool results just provided, give a concise analyst summary. "
912
+ "Classify only if the evidence supports it; otherwise say unknown and name the missing evidence. "
913
+ "Do not repeat raw tool output and do not run the same broad tools again unless one targeted follow-up is essential."
914
+ ),
915
+ })
916
+ state["request_tool_calls_seen"] = 0
917
+ try:
918
+ reply = generate_visible_reply(backend, current["messages"], cfg, current["tools"], state)
919
+ finally:
920
+ state.pop("request_tool_calls_seen", None)
921
+ print("\nxref 9b> " + reply.strip() + "\n")
922
+ save_session(active_idx)
923
+ continue
924
+
925
+ if user_text == "/verdict":
926
+ state = current["state"]
927
+ current["messages"].append({
928
+ "role": "user",
929
+ "content": (
930
+ "Give a concise final verdict now using the evidence already gathered. "
931
+ "Call final_answer with classification, confidence, summary, and key evidence. "
932
+ "Do not call additional tools; if evidence is insufficient, classify as unknown and explain why."
933
+ ),
934
+ })
935
+ state["request_tool_calls_seen"] = cfg.max_tool_calls
936
+ try:
937
+ reply = generate_visible_reply(backend, current["messages"], cfg, current["tools"], state)
938
+ finally:
939
+ state.pop("request_tool_calls_seen", None)
940
+ print("\nxref 9b> " + reply.strip() + "\n")
941
+ save_session(active_idx)
942
+ continue
943
+
944
+ current["messages"].append({"role": "user", "content": user_text})
945
+ state = current["state"]
946
+ state["request_tool_calls_seen"] = 0
947
+ try:
948
+ direct_tool = requested_direct_tool(user_text)
949
+ if direct_tool:
950
+ canonical, tool_args = direct_tool
951
+ append_direct_tool_result(canonical, tool_args, current["tools"], current["messages"], cfg, state)
952
+ reply = generate_visible_reply(backend, current["messages"], cfg, current["tools"], state)
953
+ finally:
954
+ state.pop("request_tool_calls_seen", None)
955
+ print("\nxref 9b> " + reply.strip() + "\n")
956
+ save_session(active_idx)
957
+
958
+ save_session(active_idx)
959
+ print(f"[xref 9b] transcript={run_dir / 'INSPECT.md'}")
960
+ print(f"[xref 9b] chat index={run_dir / 'CHAT.md'}")
961
+ return 0
962
+
963
+
964
+ def main(argv: list[str] | None = None) -> int:
965
+ args = list(sys.argv[1:] if argv is None else argv)
966
+ if not args or args[0] in {"-h", "--help", "help"}:
967
+ print_top_help()
968
+ return 0
969
+ command, rest = args[0], args[1:]
970
+ if command == "triage":
971
+ script = ROOT / "agentre_triage.py"
972
+ os.execv(sys.executable, [sys.executable, str(script)] + rest)
973
+ if command == "chat":
974
+ return run_chat(rest, "chat")
975
+ if command == "inspect":
976
+ return run_chat(rest, "inspect")
977
+ print(f"Unknown command: {command}\n", file=sys.stderr)
978
+ print_top_help()
979
+ return 2
980
+
981
+
982
+ if __name__ == "__main__":
983
+ raise SystemExit(main())
agentre_triage.py ADDED
@@ -0,0 +1,1265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """User-facing AgentRE PE/ELF triage CLI.
3
+
4
+ This is intentionally not an eval harness and does not require vLLM. It loads a
5
+ local Qwen/Qwen3.5-style model with an optional LoRA adapter through
6
+ Transformers, exposes static reverse-engineering tools, and lets the model
7
+ inspect one file or every PE/ELF file in a directory.
8
+
9
+ Static analysis only: samples are copied to neutral staging paths, never
10
+ executed, and tool path arguments are ignored.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import math
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import time
25
+ from dataclasses import dataclass
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+
31
+ ROOT = Path(__file__).resolve().parent
32
+ STARTER = ROOT.parents[1] / "agentre_rl_starter"
33
+
34
+ DEFAULT_BASE_MODEL = os.environ.get("XREF9B_BASE_MODEL") or os.environ.get("AGENTRE_BASE_MODEL") or "Qwen/Qwen3.5-9B"
35
+ DEFAULT_ADAPTER = os.environ.get("XREF9B_ADAPTER") or os.environ.get("AGENTRE_ADAPTER", "")
36
+ DEFAULT_SPEC = ROOT / "reverse_engineering_spec_unified.md"
37
+ DEFAULT_GHIDRA_SCRIPT_DIR = ROOT / "tools" / "ghidra_scripts"
38
+ DEFAULT_LLAMA_CLI = os.environ.get("AGENTRE_LLAMA_CLI", "llama-completion")
39
+
40
+ FINAL_TOOLS = {"final_answer", "submit_answer", "submit"}
41
+
42
+ TOOL_LIMITS = {
43
+ "file": 3000,
44
+ "strings": 9000,
45
+ "readelf": 14000,
46
+ "objdump": 18000,
47
+ "nm": 8000,
48
+ "hexdump": 8000,
49
+ "xxd": 8000,
50
+ "entropy": 2000,
51
+ "disasm_func": 18000,
52
+ "pe_headers": 14000,
53
+ "pe_sections": 9000,
54
+ "pe_imports": 14000,
55
+ "pe_exports": 9000,
56
+ "pe_disasm": 18000,
57
+ "pe_symbols": 9000,
58
+ "ghidra_summary": 24000,
59
+ }
60
+
61
+ TOOL_ALIASES = {
62
+ "file": "file",
63
+ "run_file": "file",
64
+ "strings": "strings",
65
+ "run_strings": "strings",
66
+ "readelf": "readelf",
67
+ "run_readelf": "readelf",
68
+ "objdump": "objdump",
69
+ "run_objdump": "objdump",
70
+ "nm": "nm",
71
+ "run_nm": "nm",
72
+ "hexdump": "hexdump",
73
+ "run_hexdump": "hexdump",
74
+ "xxd": "xxd",
75
+ "run_xxd": "xxd",
76
+ "entropy": "entropy",
77
+ "run_entropy": "entropy",
78
+ "disasm_func": "disasm_func",
79
+ "run_disasm_func": "disasm_func",
80
+ "pe_headers": "pe_headers",
81
+ "run_pe_headers": "pe_headers",
82
+ "pe_sections": "pe_sections",
83
+ "run_pe_sections": "pe_sections",
84
+ "pe_imports": "pe_imports",
85
+ "run_pe_imports": "pe_imports",
86
+ "pe_exports": "pe_exports",
87
+ "run_pe_exports": "pe_exports",
88
+ "pe_disasm": "pe_disasm",
89
+ "run_pe_disasm": "pe_disasm",
90
+ "pe_symbols": "pe_symbols",
91
+ "run_pe_symbols": "pe_symbols",
92
+ "ghidra_summary": "ghidra_summary",
93
+ "run_ghidra": "ghidra_summary",
94
+ }
95
+
96
+ XML_TOOL_RE = re.compile(
97
+ r"<tool_call>\s*<function=([^>\n]+)>\s*(.*?)\s*</function>\s*</tool_call>",
98
+ re.DOTALL,
99
+ )
100
+ XML_PARAM_RE = re.compile(
101
+ r"<parameter=([^>\n]+)>\s*(.*?)\s*</parameter>",
102
+ re.DOTALL,
103
+ )
104
+ JSON_TOOL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
105
+ THINK_RE = re.compile(r"<think>\s*(.*?)\s*</think>\s*", re.DOTALL)
106
+
107
+
108
+ def _now_stamp() -> str:
109
+ return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
110
+
111
+
112
+ def _json_from(text: str, pos: int = 0) -> Any | None:
113
+ decoder = json.JSONDecoder()
114
+ i = text.find("{", pos)
115
+ while i >= 0:
116
+ try:
117
+ obj, _ = decoder.raw_decode(text[i:])
118
+ return obj
119
+ except Exception:
120
+ i = text.find("{", i + 1)
121
+ return None
122
+
123
+
124
+ def _parse_json_maybe(value: Any) -> Any:
125
+ if not isinstance(value, str):
126
+ return value
127
+ text = value.strip()
128
+ if text.startswith("```"):
129
+ lines = text.splitlines()
130
+ if lines and lines[0].lstrip().startswith("```"):
131
+ lines = lines[1:]
132
+ if lines and lines[-1].strip().startswith("```"):
133
+ lines = lines[:-1]
134
+ text = "\n".join(lines).strip()
135
+ try:
136
+ return json.loads(text)
137
+ except Exception:
138
+ obj = _json_from(text)
139
+ return obj if obj is not None else value
140
+
141
+
142
+ def _parse_args(raw: Any) -> dict[str, Any]:
143
+ if isinstance(raw, dict):
144
+ return raw
145
+ if raw in (None, ""):
146
+ return {}
147
+ parsed = _parse_json_maybe(raw)
148
+ return parsed if isinstance(parsed, dict) else {}
149
+
150
+
151
+ def _tool_call(name: str, args: Any, idx: int) -> dict[str, Any]:
152
+ if not isinstance(args, dict):
153
+ args = {}
154
+ return {
155
+ "id": f"call_{idx}",
156
+ "type": "function",
157
+ "function": {"name": name, "arguments": args},
158
+ }
159
+
160
+
161
+ def parse_tool_calls(text: str | None) -> list[dict[str, Any]]:
162
+ content = text or ""
163
+ calls: list[dict[str, Any]] = []
164
+
165
+ for idx, match in enumerate(XML_TOOL_RE.finditer(content)):
166
+ name = match.group(1).strip()
167
+ body = match.group(2)
168
+ args: dict[str, Any] = {}
169
+ for param in XML_PARAM_RE.finditer(body):
170
+ key = param.group(1).strip()
171
+ value = param.group(2).strip()
172
+ args[key] = _parse_json_maybe(value)
173
+ calls.append(_tool_call(name, args, idx))
174
+ if calls:
175
+ return calls
176
+
177
+ for idx, match in enumerate(JSON_TOOL_RE.finditer(content)):
178
+ try:
179
+ obj = json.loads(match.group(1))
180
+ except Exception:
181
+ continue
182
+ name = obj.get("name") or obj.get("function", {}).get("name")
183
+ args = obj.get("arguments") or obj.get("function", {}).get("arguments") or {}
184
+ if name:
185
+ calls.append(_tool_call(str(name), _parse_args(args), idx))
186
+ if calls:
187
+ return calls
188
+
189
+ lower = content.lower()
190
+ for marker in ("final_answer", "submit_answer", "submit"):
191
+ pos = lower.find(marker)
192
+ if pos >= 0:
193
+ obj = _json_from(content, pos)
194
+ if isinstance(obj, dict):
195
+ if obj.get("name") in FINAL_TOOLS:
196
+ return [_tool_call(str(obj["name"]), obj.get("arguments", {}), 0)]
197
+ return [_tool_call(marker, obj, 0)]
198
+
199
+ obj = _json_from(content)
200
+ if isinstance(obj, dict) and (
201
+ "classification" in obj
202
+ or "recommended_label" in obj
203
+ or "malware" in obj
204
+ or "hackware" in obj
205
+ ):
206
+ return [_tool_call("final_answer", {"answer_json": obj}, 0)]
207
+ return []
208
+
209
+
210
+ def split_thinking(text: str) -> tuple[str | None, str]:
211
+ match = THINK_RE.search(text)
212
+ if match:
213
+ reasoning = match.group(1).strip()
214
+ visible = text[: match.start()] + text[match.end() :]
215
+ return reasoning, visible.strip()
216
+
217
+ # llama.cpp completion receives the opening <think> in the prompt, so the
218
+ # generated text may contain only the closing tag. Treat the prefix as
219
+ # hidden reasoning in that case.
220
+ end_tag = "</think>"
221
+ pos = text.find(end_tag)
222
+ if pos >= 0:
223
+ reasoning = text[:pos].strip()
224
+ visible = text[pos + len(end_tag) :].strip()
225
+ return reasoning or None, visible
226
+ return None, text
227
+
228
+
229
+ def strip_tool_xml(text: str) -> str:
230
+ text = XML_TOOL_RE.sub("", text)
231
+ text = JSON_TOOL_RE.sub("", text)
232
+ return text.strip()
233
+
234
+
235
+ def final_answer_from_args(args: dict[str, Any]) -> dict[str, Any] | None:
236
+ answer = args.get("answer_json", args)
237
+ answer = _parse_json_maybe(answer)
238
+ return answer if isinstance(answer, dict) else None
239
+
240
+
241
+ def normalize_prediction(answer: Any) -> str:
242
+ if not isinstance(answer, dict):
243
+ return "unknown"
244
+ for key in ("classification", "recommended_label", "label", "verdict"):
245
+ value = answer.get(key)
246
+ if isinstance(value, str):
247
+ return normalize_prediction_string(value)
248
+ if bool(answer.get("hackware")):
249
+ return "hackware"
250
+ if isinstance(answer.get("malware"), bool):
251
+ return "malicious" if answer["malware"] else "benign"
252
+ return normalize_prediction_string(json.dumps(answer, sort_keys=True))
253
+
254
+
255
+ def normalize_prediction_string(value: str) -> str:
256
+ text = value.lower()
257
+ if "hackware" in text or "dual-use" in text or "offensive tool" in text:
258
+ return "hackware"
259
+ if "benign" in text or "not malware" in text or "non-malicious" in text:
260
+ return "benign"
261
+ if "malicious" in text or "malware" in text or "c2" in text or "reverse shell" in text:
262
+ return "malicious"
263
+ return "unknown"
264
+
265
+
266
+ def openai_tool(name: str, description: str, props: dict[str, Any] | None = None) -> dict[str, Any]:
267
+ return {
268
+ "type": "function",
269
+ "function": {
270
+ "name": name,
271
+ "description": description,
272
+ "parameters": {
273
+ "type": "object",
274
+ "properties": props or {},
275
+ "required": [],
276
+ },
277
+ },
278
+ }
279
+
280
+
281
+ def tool_definitions() -> list[dict[str, Any]]:
282
+ path_prop = {"path": {"type": "string", "description": "Optional. Ignored; routed to current sample."}}
283
+ str_props = dict(path_prop)
284
+ str_props["min_length"] = {"type": "integer", "description": "Minimum printable string length."}
285
+ dump_props = dict(path_prop)
286
+ dump_props["length"] = {"type": "integer", "description": "Maximum bytes to dump."}
287
+ dump_props["offset"] = {"type": "integer", "description": "Start offset in bytes."}
288
+ disasm_props = {"function": {"type": "string", "description": "Function symbol name to disassemble."}}
289
+ ghidra_props = {
290
+ "timeout": {"type": "integer", "description": "Optional max seconds for Ghidra analysis."}
291
+ }
292
+ final_props = {"answer_json": {"type": "object", "description": "Final JSON verdict."}}
293
+ T = openai_tool
294
+ return [
295
+ T("run_file", "Identify file type, format, architecture, linkage, and stripped status.", path_prop),
296
+ T("file", "Alias for run_file.", path_prop),
297
+ T("run_strings", "Extract printable strings from the sample.", str_props),
298
+ T("strings", "Alias for run_strings.", str_props),
299
+ T("run_readelf", "Inspect ELF headers, sections, symbols, program headers, and dynamic imports.", path_prop),
300
+ T("readelf", "Alias for run_readelf.", path_prop),
301
+ T("run_objdump", "Disassemble or dump ELF sections with objdump.", path_prop),
302
+ T("objdump", "Alias for run_objdump.", path_prop),
303
+ T("nm", "List symbols when present.", path_prop),
304
+ T("run_pe_headers", "PE file and optional headers.", path_prop),
305
+ T("pe_headers", "Alias for run_pe_headers.", path_prop),
306
+ T("run_pe_sections", "PE section table and entropy.", path_prop),
307
+ T("pe_sections", "Alias for run_pe_sections.", path_prop),
308
+ T("run_pe_imports", "PE import tables and WinAPI symbols.", path_prop),
309
+ T("pe_imports", "Alias for run_pe_imports.", path_prop),
310
+ T("run_pe_exports", "PE export table and data directories.", path_prop),
311
+ T("pe_exports", "Alias for run_pe_exports.", path_prop),
312
+ T("run_pe_disasm", "Disassemble a PE sample.", path_prop),
313
+ T("pe_disasm", "Alias for run_pe_disasm.", path_prop),
314
+ T("run_pe_symbols", "List PE symbols when present.", path_prop),
315
+ T("pe_symbols", "Alias for run_pe_symbols.", path_prop),
316
+ T("run_disasm_func", "Disassemble one named function.", disasm_props),
317
+ T("disasm_func", "Alias for run_disasm_func.", disasm_props),
318
+ T("hexdump", "Hex dump bytes from the sample.", dump_props),
319
+ T("xxd", "Alias hex dump using xxd.", dump_props),
320
+ T("entropy", "Compute whole-file Shannon entropy.", path_prop),
321
+ T("ghidra_summary", "Run Ghidra headless static analysis and summarize output.", ghidra_props),
322
+ T("final_answer", "Submit exactly one final JSON verdict.", final_props),
323
+ T("submit_answer", "Alias for final_answer.", final_props),
324
+ ]
325
+
326
+
327
+ def render_qwen_xml_tool_call(name: str, args: dict[str, Any]) -> str:
328
+ parts = ["<tool_call>\n", f"<function={name}>\n"]
329
+ for key, value in args.items():
330
+ if isinstance(value, (dict, list)):
331
+ value_text = json.dumps(value, ensure_ascii=True)
332
+ else:
333
+ value_text = str(value)
334
+ parts.extend([f"<parameter={key}>\n", value_text, "\n</parameter>\n"])
335
+ parts.append("</function>\n</tool_call>")
336
+ return "".join(parts)
337
+
338
+
339
+ def render_qwen_xml_chat(
340
+ messages: list[dict[str, Any]],
341
+ tools: list[dict[str, Any]],
342
+ thinking: bool,
343
+ add_generation_prompt: bool = True,
344
+ ) -> str:
345
+ """Render a Qwen3.5 XML-tool prompt for llama.cpp GGUF inference."""
346
+ chunks: list[str] = []
347
+ system_content = ""
348
+ start_index = 0
349
+ if messages and messages[0].get("role") == "system":
350
+ system_content = str(messages[0].get("content") or "").strip()
351
+ start_index = 1
352
+
353
+ if tools:
354
+ chunks.append("<|im_start|>system\n")
355
+ chunks.append("# Tools\n\nYou have access to the following functions:\n\n<tools>")
356
+ for tool in tools:
357
+ chunks.append("\n")
358
+ chunks.append(json.dumps(tool, ensure_ascii=True))
359
+ chunks.append("\n</tools>")
360
+ chunks.append(
361
+ "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n"
362
+ "<tool_call>\n<function=example_function_name>\n"
363
+ "<parameter=example_parameter_1>\nvalue_1\n</parameter>\n"
364
+ "<parameter=example_parameter_2>\nThis is the value for the second parameter\n"
365
+ "that can span\nmultiple lines\n</parameter>\n"
366
+ "</function>\n</tool_call>\n\n"
367
+ "<IMPORTANT>\n"
368
+ "Reminder:\n"
369
+ "- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n"
370
+ "- Required parameters MUST be specified\n"
371
+ "- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n"
372
+ "- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n"
373
+ "</IMPORTANT>"
374
+ )
375
+ if system_content:
376
+ chunks.append("\n\n")
377
+ chunks.append(system_content)
378
+ chunks.append("<|im_end|>\n")
379
+ elif system_content:
380
+ chunks.append("<|im_start|>system\n")
381
+ chunks.append(system_content)
382
+ chunks.append("<|im_end|>\n")
383
+
384
+ i = start_index
385
+ while i < len(messages):
386
+ msg = messages[i]
387
+ role = msg.get("role")
388
+ content = str(msg.get("content") or "").strip()
389
+ if role == "user":
390
+ chunks.append("<|im_start|>user\n")
391
+ chunks.append(content)
392
+ chunks.append("<|im_end|>\n")
393
+ elif role == "assistant":
394
+ chunks.append("<|im_start|>assistant\n")
395
+ reasoning = str(msg.get("reasoning_content") or "").strip()
396
+ if reasoning:
397
+ chunks.append("<think>\n")
398
+ chunks.append(reasoning)
399
+ chunks.append("\n</think>\n\n")
400
+ elif msg.get("reasoning_content") is not None:
401
+ chunks.append("<think>\n\n</think>\n\n")
402
+ chunks.append(content)
403
+ tool_calls = msg.get("tool_calls") or []
404
+ if tool_calls:
405
+ if content:
406
+ chunks.append("\n\n")
407
+ for tc in tool_calls:
408
+ fn = tc.get("function", {})
409
+ name = str(fn.get("name") or "")
410
+ args = _parse_args(fn.get("arguments"))
411
+ chunks.append(render_qwen_xml_tool_call(name, args))
412
+ chunks.append("\n")
413
+ chunks.append("<|im_end|>\n")
414
+ elif role == "tool":
415
+ chunks.append("<|im_start|>user")
416
+ while i < len(messages) and messages[i].get("role") == "tool":
417
+ tool_msg = messages[i]
418
+ chunks.append("\n<tool_response>\n")
419
+ chunks.append(str(tool_msg.get("content") or ""))
420
+ chunks.append("\n</tool_response>")
421
+ i += 1
422
+ chunks.append("<|im_end|>\n")
423
+ continue
424
+ i += 1
425
+
426
+ if add_generation_prompt:
427
+ chunks.append("<|im_start|>assistant\n")
428
+ if thinking:
429
+ chunks.append("<think>\n")
430
+ else:
431
+ chunks.append("<think>\n\n</think>\n\n")
432
+ return "".join(chunks)
433
+
434
+
435
+ def _parse_int(value: Any, default: int) -> int:
436
+ try:
437
+ if isinstance(value, str):
438
+ return int(value.strip(), 0)
439
+ return int(value)
440
+ except Exception:
441
+ return default
442
+
443
+
444
+ def _dump_bounds(length: Any, offset: Any) -> tuple[int, int]:
445
+ length_i = _parse_int(length, 1024)
446
+ offset_i = _parse_int(offset, 0)
447
+ return max(64, min(length_i, 8192)), max(0, offset_i)
448
+
449
+
450
+ def detect_format(path: Path) -> str:
451
+ try:
452
+ magic = path.read_bytes()[:4]
453
+ except Exception:
454
+ return "unknown"
455
+ if magic == b"\x7fELF":
456
+ return "ELF"
457
+ if magic[:2] == b"MZ":
458
+ return "PE"
459
+ return "unknown"
460
+
461
+
462
+ def collect_targets(path: Path, include_unknown: bool, max_files: int) -> list[Path]:
463
+ if path.is_file():
464
+ return [path]
465
+ if not path.is_dir():
466
+ raise FileNotFoundError(path)
467
+ out: list[Path] = []
468
+ for p in sorted(path.rglob("*")):
469
+ if not p.is_file() or p.is_symlink():
470
+ continue
471
+ fmt = detect_format(p)
472
+ if fmt in {"PE", "ELF"} or include_unknown:
473
+ out.append(p)
474
+ if max_files and len(out) >= max_files:
475
+ break
476
+ return out
477
+
478
+
479
+ @dataclass
480
+ class RuntimeConfig:
481
+ max_turns: int
482
+ max_tool_calls: int
483
+ max_tokens: int
484
+ obs_limit: int
485
+ temperature: float
486
+ thinking: bool
487
+ save_transcripts: bool
488
+ save_reasoning: bool
489
+ ghidra_script_dir: Path
490
+
491
+
492
+ class StaticTools:
493
+ def __init__(self, staged_path: Path, fmt: str, obs_limit: int, ghidra_script_dir: Path):
494
+ self.staged_path = staged_path
495
+ self.format = fmt
496
+ self.obs_limit = obs_limit
497
+ self.ghidra_script_dir = ghidra_script_dir
498
+ self.used_tools: list[str] = []
499
+ self.file_metadata: str | None = None
500
+ self.mingw_objdump = shutil.which("x86_64-w64-mingw32-objdump") or "objdump"
501
+ self.mingw_nm = shutil.which("x86_64-w64-mingw32-nm") or "nm"
502
+
503
+ def _limit(self, text: str, limit: int) -> str:
504
+ limit = min(limit, self.obs_limit)
505
+ text = text.replace(str(self.staged_path), "<sample>")
506
+ if len(text) > limit:
507
+ return text[:limit] + f"\n[truncated to {limit} chars]"
508
+ return text
509
+
510
+ def _run(self, cmd: list[str], tool_name: str, limit: int, timeout: int = 45) -> str:
511
+ self.used_tools.append(tool_name)
512
+ try:
513
+ proc = subprocess.run(
514
+ cmd,
515
+ stdout=subprocess.PIPE,
516
+ stderr=subprocess.PIPE,
517
+ text=True,
518
+ errors="replace",
519
+ timeout=timeout,
520
+ cwd=str(self.staged_path.parent),
521
+ check=False,
522
+ )
523
+ except FileNotFoundError:
524
+ return f"tool unavailable: {cmd[0]} not found"
525
+ except subprocess.TimeoutExpired:
526
+ return f"tool timeout after {timeout}s: {' '.join(cmd[:3])}"
527
+ out = proc.stdout
528
+ if proc.stderr:
529
+ out += "\n[stderr]\n" + proc.stderr
530
+ if not out.strip():
531
+ out = f"{tool_name} completed with no output, exit={proc.returncode}"
532
+ return self._limit(out, limit)
533
+
534
+ def file(self, **_: Any) -> str:
535
+ out = self._run(["file", "-b", str(self.staged_path)], "file", TOOL_LIMITS["file"], timeout=20)
536
+ self.file_metadata = out
537
+ return out
538
+
539
+ def strings(self, min_length: Any = 5, **_: Any) -> str:
540
+ try:
541
+ n = int(min_length)
542
+ except Exception:
543
+ n = 5
544
+ n = max(3, min(n, 32))
545
+ return self._run(["strings", "-a", "-n", str(n), str(self.staged_path)], "strings", TOOL_LIMITS["strings"], 35)
546
+
547
+ def readelf(self, **_: Any) -> str:
548
+ if self.format != "ELF":
549
+ return "readelf is only useful for ELF samples; use PE tools for PE files."
550
+ return self._run(["readelf", "-aW", str(self.staged_path)], "readelf", TOOL_LIMITS["readelf"], 35)
551
+
552
+ def objdump(self, **_: Any) -> str:
553
+ if self.format == "PE":
554
+ return self.pe_disasm()
555
+ tool = shutil.which("x86_64-linux-gnu-objdump") or "objdump"
556
+ cmd = [tool, "-d", "-M", "intel", str(self.staged_path)] if self._is_x86() else [tool, "-d", str(self.staged_path)]
557
+ return self._run(cmd, "objdump", TOOL_LIMITS["objdump"], 60)
558
+
559
+ def nm(self, **_: Any) -> str:
560
+ nm_tool = self.mingw_nm if self.format == "PE" else "nm"
561
+ return self._run([nm_tool, "-an", str(self.staged_path)], "nm", TOOL_LIMITS["nm"], 30)
562
+
563
+ def disasm_func(self, function: Any = "", **_: Any) -> str:
564
+ name = str(function or "").strip()
565
+ if not name:
566
+ self.used_tools.append("disasm_func")
567
+ return "disasm_func requires a function symbol name."
568
+ if self.format == "PE":
569
+ cmd = [self.mingw_objdump, "-d", "-M", "intel", f"--disassemble={name}", str(self.staged_path)]
570
+ else:
571
+ tool = shutil.which("x86_64-linux-gnu-objdump") or "objdump"
572
+ cmd = [tool, "-d", "-M", "intel", f"--disassemble={name}", str(self.staged_path)] if self._is_x86() else [tool, "-d", f"--disassemble={name}", str(self.staged_path)]
573
+ return self._run(cmd, "disasm_func", TOOL_LIMITS["disasm_func"], 60)
574
+
575
+ def pe_headers(self, **_: Any) -> str:
576
+ if self.format != "PE":
577
+ return "pe_headers is only useful for PE samples; use readelf for ELF files."
578
+ a = self._run([self.mingw_objdump, "-f", str(self.staged_path)], "pe_headers", TOOL_LIMITS["pe_headers"], 25)
579
+ b = self._run([self.mingw_objdump, "-p", str(self.staged_path)], "pe_headers", TOOL_LIMITS["pe_headers"], 45)
580
+ return self._limit(a + "\n\n== private headers (-p) ==\n" + b, TOOL_LIMITS["pe_headers"])
581
+
582
+ def pe_sections(self, **_: Any) -> str:
583
+ if self.format != "PE":
584
+ return "pe_sections is only useful for PE samples."
585
+ hdr = self._run([self.mingw_objdump, "-h", str(self.staged_path)], "pe_sections", TOOL_LIMITS["pe_sections"], 25)
586
+ return self._limit(hdr + "\n\n== per-section entropy ==\n" + self._section_entropy(hdr), TOOL_LIMITS["pe_sections"])
587
+
588
+ def pe_imports(self, **_: Any) -> str:
589
+ if self.format != "PE":
590
+ return "pe_imports is only useful for PE samples."
591
+ return self._run([self.mingw_objdump, "-x", str(self.staged_path)], "pe_imports", TOOL_LIMITS["pe_imports"], 45)
592
+
593
+ def pe_exports(self, **_: Any) -> str:
594
+ if self.format != "PE":
595
+ return "pe_exports is only useful for PE samples."
596
+ return self._run([self.mingw_objdump, "-p", str(self.staged_path)], "pe_exports", TOOL_LIMITS["pe_exports"], 45)
597
+
598
+ def pe_disasm(self, **_: Any) -> str:
599
+ if self.format != "PE":
600
+ return "pe_disasm is only useful for PE samples; use objdump for ELF files."
601
+ return self._run([self.mingw_objdump, "-d", "-M", "intel", str(self.staged_path)], "pe_disasm", TOOL_LIMITS["pe_disasm"], 70)
602
+
603
+ def pe_symbols(self, **_: Any) -> str:
604
+ if self.format != "PE":
605
+ return "pe_symbols is only useful for PE samples; use nm for ELF files."
606
+ return self._run([self.mingw_nm, str(self.staged_path)], "pe_symbols", TOOL_LIMITS["pe_symbols"], 30)
607
+
608
+ def hexdump(self, length: Any = 1024, offset: Any = 0, **_: Any) -> str:
609
+ length_i, offset_i = _dump_bounds(length, offset)
610
+ if shutil.which("hexdump"):
611
+ return self._run(["hexdump", "-C", "-n", str(length_i), "-s", str(offset_i), str(self.staged_path)], "hexdump", TOOL_LIMITS["hexdump"], 20)
612
+ return self.xxd(length=length_i, offset=offset_i)
613
+
614
+ def xxd(self, length: Any = 1024, offset: Any = 0, **_: Any) -> str:
615
+ length_i, offset_i = _dump_bounds(length, offset)
616
+ return self._run(["xxd", "-g", "1", "-l", str(length_i), "-s", str(offset_i), str(self.staged_path)], "xxd", TOOL_LIMITS["xxd"], 20)
617
+
618
+ def entropy(self, **_: Any) -> str:
619
+ self.used_tools.append("entropy")
620
+ counts = [0] * 256
621
+ total = 0
622
+ with self.staged_path.open("rb") as fh:
623
+ while True:
624
+ chunk = fh.read(1 << 20)
625
+ if not chunk:
626
+ break
627
+ total += len(chunk)
628
+ for b in chunk:
629
+ counts[b] += 1
630
+ ent = 0.0 if total == 0 else -sum((c / total) * math.log2(c / total) for c in counts if c)
631
+ return json.dumps({"bytes": total, "shannon_entropy": round(ent, 4)})
632
+
633
+ def ghidra_summary(self, timeout: Any = 180, **_: Any) -> str:
634
+ self.used_tools.append("ghidra_summary")
635
+ try:
636
+ timeout_i = max(30, min(int(timeout), 900))
637
+ except Exception:
638
+ timeout_i = 180
639
+ headless = find_ghidra_headless()
640
+ if not headless:
641
+ return "ghidra unavailable: analyzeHeadless not found. Set GHIDRA_HEADLESS or add analyzeHeadless to PATH."
642
+ script_dir = self.ghidra_script_dir
643
+ script_file = script_dir / "AgentRESummary.java"
644
+ if not script_file.exists():
645
+ return f"ghidra script unavailable: {script_file}"
646
+ with tempfile.TemporaryDirectory(prefix="agentre_ghidra_") as tmp:
647
+ tmp_path = Path(tmp)
648
+ out_path = tmp_path / "agentre_ghidra_summary.txt"
649
+ cmd = [
650
+ headless,
651
+ str(tmp_path),
652
+ "agentre_project",
653
+ "-import",
654
+ str(self.staged_path),
655
+ "-overwrite",
656
+ "-analysisTimeoutPerFile",
657
+ str(timeout_i),
658
+ "-scriptPath",
659
+ str(script_dir),
660
+ "-postScript",
661
+ "AgentRESummary.java",
662
+ str(out_path),
663
+ ]
664
+ try:
665
+ proc = subprocess.run(
666
+ cmd,
667
+ stdout=subprocess.PIPE,
668
+ stderr=subprocess.PIPE,
669
+ text=True,
670
+ errors="replace",
671
+ timeout=timeout_i + 90,
672
+ check=False,
673
+ )
674
+ except subprocess.TimeoutExpired:
675
+ return f"ghidra timeout after {timeout_i + 90}s"
676
+ out = out_path.read_text(encoding="utf-8", errors="replace") if out_path.exists() else ""
677
+ log = proc.stdout + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
678
+ if out.strip():
679
+ return self._limit(out + "\n\n== Ghidra log tail ==\n" + log[-4000:], TOOL_LIMITS["ghidra_summary"])
680
+ return self._limit("Ghidra produced no summary file.\n\n" + log, TOOL_LIMITS["ghidra_summary"])
681
+
682
+ def _is_x86(self) -> bool:
683
+ meta = self.file_metadata or self.file()
684
+ low = meta.lower()
685
+ return "x86-64" in low or "80386" in low or "intel" in low or "amd64" in low
686
+
687
+ def _section_entropy(self, section_table: str) -> str:
688
+ try:
689
+ data = self.staged_path.read_bytes()
690
+ except Exception as exc:
691
+ return f"<cannot read: {exc}>"
692
+ rows: list[str] = []
693
+ for line in section_table.splitlines():
694
+ parts = line.split()
695
+ if len(parts) >= 6 and parts[0].isdigit():
696
+ try:
697
+ size, off = int(parts[2], 16), int(parts[5], 16)
698
+ except ValueError:
699
+ continue
700
+ blob = data[off : off + size]
701
+ if not blob:
702
+ rows.append(f"{parts[1]:14s} size={size:<8} entropy=n/a")
703
+ continue
704
+ counts = [0] * 256
705
+ for b in blob:
706
+ counts[b] += 1
707
+ n = len(blob)
708
+ ent = -sum((v / n) * math.log2(v / n) for v in counts if v)
709
+ flag = " <HIGH: packed/encrypted?>" if ent > 7.2 else ""
710
+ rows.append(f"{parts[1]:14s} size={size:<8} entropy={ent:4.2f}{flag}")
711
+ return "\n".join(rows) or "<no section table>"
712
+
713
+
714
+ def find_ghidra_headless() -> str | None:
715
+ env = os.environ.get("GHIDRA_HEADLESS")
716
+ if env and Path(env).exists():
717
+ return env
718
+ found = shutil.which("analyzeHeadless")
719
+ if found:
720
+ return found
721
+
722
+ # Keep this bounded. A recursive home-directory scan can hang on large
723
+ # workstations, so only check common Ghidra install layouts.
724
+ candidates = [
725
+ Path("/opt/ghidra/support/analyzeHeadless"),
726
+ Path("/usr/share/ghidra/support/analyzeHeadless"),
727
+ Path.home() / "ghidra" / "support" / "analyzeHeadless",
728
+ ]
729
+ candidates.extend(Path("/opt").glob("ghidra*/support/analyzeHeadless") if Path("/opt").exists() else [])
730
+ for path in candidates:
731
+ if path.is_file() and os.access(path, os.X_OK):
732
+ return str(path)
733
+ return None
734
+
735
+
736
+ class LocalQwenBackend:
737
+ def __init__(self, base_model: str, adapter: str | None, dtype: str, device_map: str):
738
+ try:
739
+ import torch
740
+ from transformers import AutoModelForCausalLM, AutoTokenizer
741
+ except Exception as exc:
742
+ raise RuntimeError("Transformers local backend requires torch and transformers") from exc
743
+
744
+ self.torch = torch
745
+ tokenizer_path = adapter if adapter and Path(adapter).exists() else base_model
746
+ self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_path, trust_remote_code=True)
747
+ torch_dtype = "auto" if dtype == "auto" else getattr(torch, dtype)
748
+ self.model = AutoModelForCausalLM.from_pretrained(
749
+ base_model,
750
+ trust_remote_code=True,
751
+ torch_dtype=torch_dtype,
752
+ device_map=device_map,
753
+ )
754
+ if adapter:
755
+ try:
756
+ from peft import PeftModel
757
+ except Exception as exc:
758
+ raise RuntimeError("Loading a LoRA adapter requires peft") from exc
759
+ self.model = PeftModel.from_pretrained(self.model, adapter)
760
+ self.model.eval()
761
+
762
+ def generate(
763
+ self,
764
+ messages: list[dict[str, Any]],
765
+ tools: list[dict[str, Any]],
766
+ max_new_tokens: int,
767
+ temperature: float,
768
+ thinking: bool,
769
+ ) -> tuple[str, dict[str, int]]:
770
+ prompt = self.tokenizer.apply_chat_template(
771
+ messages,
772
+ tools=tools,
773
+ tokenize=False,
774
+ add_generation_prompt=True,
775
+ enable_thinking=thinking,
776
+ )
777
+ inputs = self.tokenizer([prompt], return_tensors="pt")
778
+ device = next(self.model.parameters()).device
779
+ inputs = {k: v.to(device) for k, v in inputs.items()}
780
+ do_sample = temperature > 0
781
+ with self.torch.inference_mode():
782
+ output = self.model.generate(
783
+ **inputs,
784
+ max_new_tokens=max_new_tokens,
785
+ do_sample=do_sample,
786
+ temperature=temperature if do_sample else None,
787
+ pad_token_id=self.tokenizer.eos_token_id,
788
+ )
789
+ input_len = int(inputs["input_ids"].shape[1])
790
+ generated_ids = output[0][input_len:]
791
+ text = self.tokenizer.decode(generated_ids, skip_special_tokens=False)
792
+ if "<|im_end|>" in text:
793
+ text = text.split("<|im_end|>", 1)[0]
794
+ usage = {
795
+ "prompt_tokens": input_len,
796
+ "completion_tokens": int(generated_ids.shape[0]),
797
+ "total_tokens": input_len + int(generated_ids.shape[0]),
798
+ }
799
+ return text, usage
800
+
801
+
802
+
803
+ def clean_llama_completion_output(text: str) -> str:
804
+ lines: list[str] = []
805
+ for line in text.splitlines():
806
+ stripped = line.strip()
807
+ if line.startswith("ggml_cuda_init:") or line.startswith(" Device "):
808
+ continue
809
+ if stripped in {"[end of text]", ""}:
810
+ if lines:
811
+ lines.append("")
812
+ continue
813
+ lines.append(line)
814
+ cleaned = "\n".join(lines).replace(" [end of text]", "").replace("[end of text]", "").strip()
815
+ return cleaned
816
+
817
+
818
+ class LlamaCppBackend:
819
+ def __init__(
820
+ self,
821
+ model_path: str,
822
+ llama_cli: str,
823
+ ctx_size: int,
824
+ threads: int,
825
+ gpu_layers: int,
826
+ llama_lora: str | None,
827
+ extra_args: list[str],
828
+ ):
829
+ resolved_cli = shutil.which(llama_cli) or llama_cli
830
+ cli_path = Path(resolved_cli)
831
+ if cli_path.name == "llama-cli":
832
+ sibling_completion = cli_path.with_name("llama-completion")
833
+ if sibling_completion.exists():
834
+ resolved_cli = str(sibling_completion)
835
+ if not Path(resolved_cli).exists() and shutil.which(resolved_cli) is None:
836
+ raise RuntimeError(f"llama.cpp completion binary not found: {llama_cli}. Set --llama-cli or AGENTRE_LLAMA_CLI.")
837
+ if not Path(model_path).exists():
838
+ raise RuntimeError(f"GGUF model not found: {model_path}")
839
+ self.llama_cli = resolved_cli
840
+ self.model_path = model_path
841
+ self.ctx_size = ctx_size
842
+ self.threads = threads
843
+ self.gpu_layers = gpu_layers
844
+ self.llama_lora = llama_lora
845
+ self.extra_args = extra_args
846
+
847
+ def generate(
848
+ self,
849
+ messages: list[dict[str, Any]],
850
+ tools: list[dict[str, Any]],
851
+ max_new_tokens: int,
852
+ temperature: float,
853
+ thinking: bool,
854
+ ) -> tuple[str, dict[str, int]]:
855
+ prompt = render_qwen_xml_chat(messages, tools, thinking=thinking, add_generation_prompt=True)
856
+
857
+ def build_cmd(ctx_size: int) -> list[str]:
858
+ cmd = [
859
+ self.llama_cli,
860
+ "-m",
861
+ self.model_path,
862
+ "-p",
863
+ prompt,
864
+ "-n",
865
+ str(max_new_tokens),
866
+ "-c",
867
+ str(ctx_size),
868
+ "--temp",
869
+ str(max(0.0, temperature)),
870
+ "--no-display-prompt",
871
+ "-no-cnv",
872
+ "--simple-io",
873
+ "--no-warmup",
874
+ "-lv",
875
+ "1",
876
+ ]
877
+ if self.threads > 0:
878
+ cmd.extend(["-t", str(self.threads)])
879
+ if self.gpu_layers >= 0:
880
+ cmd.extend(["-ngl", str(self.gpu_layers)])
881
+ if self.llama_lora:
882
+ cmd.extend(["--lora", self.llama_lora])
883
+ cmd.extend(self.extra_args)
884
+ return cmd
885
+
886
+ ctx_size = self.ctx_size
887
+ proc: subprocess.CompletedProcess[str] | None = None
888
+ for attempt in range(2):
889
+ try:
890
+ proc = subprocess.run(
891
+ build_cmd(ctx_size),
892
+ stdout=subprocess.PIPE,
893
+ stderr=subprocess.PIPE,
894
+ text=True,
895
+ errors="replace",
896
+ timeout=900,
897
+ check=False,
898
+ )
899
+ except FileNotFoundError as exc:
900
+ raise RuntimeError(f"llama.cpp CLI not found: {self.llama_cli}") from exc
901
+
902
+ if proc.returncode == 0:
903
+ self.ctx_size = ctx_size
904
+ break
905
+
906
+ error_text = proc.stderr[-4000:] or proc.stdout[-4000:]
907
+ match = re.search(r"prompt is too long \((\d+) tokens, max (\d+)\)", error_text)
908
+ if match and attempt == 0:
909
+ prompt_tokens = int(match.group(1))
910
+ needed = prompt_tokens + max_new_tokens + 128
911
+ next_ctx = max(ctx_size * 2, ((needed + 4095) // 4096) * 4096)
912
+ print(
913
+ f"[xref 9b] llama.cpp prompt exceeded ctx={ctx_size}; retrying with ctx={next_ctx}",
914
+ file=sys.stderr,
915
+ flush=True,
916
+ )
917
+ ctx_size = next_ctx
918
+ continue
919
+
920
+ raise RuntimeError(
921
+ "llama.cpp generation failed "
922
+ f"exit={proc.returncode}: {error_text}"
923
+ )
924
+
925
+ if proc is None:
926
+ raise RuntimeError("llama.cpp generation did not start")
927
+ if proc.returncode != 0:
928
+ error_text = proc.stderr[-4000:] or proc.stdout[-4000:]
929
+ raise RuntimeError(
930
+ "llama.cpp generation failed "
931
+ f"exit={proc.returncode}: {error_text}"
932
+ )
933
+
934
+ text = clean_llama_completion_output(proc.stdout)
935
+ if "<|im_end|>" in text:
936
+ text = text.split("<|im_end|>", 1)[0]
937
+ return text, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
938
+
939
+
940
+ def build_messages(spec: str, sample_id: str, fmt: str, user_request: str) -> list[dict[str, Any]]:
941
+ system = (
942
+ spec.strip()
943
+ + "\n\nRuntime rules:\n"
944
+ + f"- You are analyzing staged sample `{sample_id}` only.\n"
945
+ + f"- Detected format: {fmt}.\n"
946
+ + "- The original path is hidden from the model to avoid path/name bias.\n"
947
+ + "- Any tool path argument is ignored; tools are routed to the staged sample.\n"
948
+ + "- Use static analysis only. Never ask to execute the sample.\n"
949
+ + "- If evidence is insufficient, classify as unknown and state that closer disassembly with Ghidra or another disassembler is needed.\n"
950
+ + "- For stripped, static, packed, encrypted, or sparse-string samples, prefer entropy, objdump/PE disassembly, and ghidra_summary before final_answer.\n"
951
+ + "- Think internally, but do not include chain-of-thought in the final answer.\n"
952
+ )
953
+ user = (
954
+ f"{user_request.strip()}\n\n"
955
+ "Use the available reverse-engineering tools. Decide whether this sample is benign, malicious, hackware, or unknown. "
956
+ "When finished, call final_answer with the structured JSON verdict."
957
+ )
958
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
959
+
960
+
961
+ def make_history_tool_call(tc: dict[str, Any], fallback_id: str) -> dict[str, Any]:
962
+ fn = tc.get("function", {})
963
+ args = _parse_args(fn.get("arguments"))
964
+ return {
965
+ "id": tc.get("id") or fallback_id,
966
+ "type": "function",
967
+ "function": {
968
+ "name": str(fn.get("name") or ""),
969
+ "arguments": args,
970
+ },
971
+ }
972
+
973
+
974
+ def analyze_one(
975
+ backend: Any,
976
+ source_path: Path,
977
+ sample_id: str,
978
+ run_dir: Path,
979
+ spec: str,
980
+ cfg: RuntimeConfig,
981
+ user_request: str,
982
+ ) -> dict[str, Any]:
983
+ fmt = detect_format(source_path)
984
+ stage_dir = run_dir / "staged"
985
+ stage_dir.mkdir(parents=True, exist_ok=True)
986
+ suffix = ".elf" if fmt == "ELF" else ".exe" if fmt == "PE" else source_path.suffix
987
+ staged_path = (stage_dir / f"{sample_id}{suffix}").resolve()
988
+ shutil.copy2(source_path, staged_path)
989
+
990
+ tools = StaticTools(staged_path, fmt, cfg.obs_limit, cfg.ghidra_script_dir)
991
+ messages = build_messages(spec, sample_id, fmt, user_request)
992
+ answer: dict[str, Any] | None = None
993
+ error: str | None = None
994
+ usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
995
+ tool_calls_seen = 0
996
+ transcript: list[dict[str, Any]] = list(messages)
997
+
998
+ try:
999
+ for turn in range(cfg.max_turns):
1000
+ if tool_calls_seen >= cfg.max_tool_calls:
1001
+ messages.append({"role": "user", "content": "Tool budget reached. Call final_answer now with your best JSON verdict."})
1002
+ transcript.append(messages[-1])
1003
+
1004
+ generated, step_usage = backend.generate(
1005
+ messages,
1006
+ tool_definitions(),
1007
+ cfg.max_tokens,
1008
+ cfg.temperature,
1009
+ cfg.thinking,
1010
+ )
1011
+ for key, value in step_usage.items():
1012
+ usage[key] = usage.get(key, 0) + int(value)
1013
+
1014
+ reasoning, visible = split_thinking(generated)
1015
+ tool_calls = parse_tool_calls(generated)
1016
+ assistant_content = strip_tool_xml(visible)
1017
+ assistant_msg: dict[str, Any] = {"role": "assistant", "content": assistant_content}
1018
+ if reasoning and cfg.save_reasoning:
1019
+ assistant_msg["reasoning_content"] = reasoning
1020
+ elif reasoning:
1021
+ assistant_msg["reasoning_content"] = ""
1022
+ if tool_calls:
1023
+ assistant_msg["tool_calls"] = [
1024
+ make_history_tool_call(tc, f"call_{turn}_{idx}")
1025
+ for idx, tc in enumerate(tool_calls)
1026
+ ]
1027
+ messages.append(assistant_msg)
1028
+ transcript.append(dict(assistant_msg))
1029
+
1030
+ if not tool_calls:
1031
+ parsed = final_answer_from_args({"answer_json": assistant_content or generated})
1032
+ if parsed:
1033
+ answer = parsed
1034
+ break
1035
+ if turn < cfg.max_turns - 1:
1036
+ prompt = "Call final_answer now with your compact JSON verdict."
1037
+ messages.append({"role": "user", "content": prompt})
1038
+ transcript.append(messages[-1])
1039
+ continue
1040
+ break
1041
+
1042
+ stop = False
1043
+ for idx, tc in enumerate(tool_calls):
1044
+ fn = tc.get("function", {})
1045
+ name = str(fn.get("name") or "")
1046
+ args = _parse_args(fn.get("arguments"))
1047
+ tcid = tc.get("id") or f"call_{turn}_{idx}"
1048
+ if name in FINAL_TOOLS:
1049
+ answer = final_answer_from_args(args)
1050
+ msg = {"role": "tool", "tool_call_id": tcid, "name": name, "content": "submitted"}
1051
+ messages.append(msg)
1052
+ transcript.append(msg)
1053
+ stop = True
1054
+ break
1055
+ canonical = TOOL_ALIASES.get(name)
1056
+ if canonical is None:
1057
+ content = f"unknown tool {name}. Use the tools listed in the unified spec and final_answer."
1058
+ elif tool_calls_seen >= cfg.max_tool_calls:
1059
+ content = "tool budget exhausted; submit final_answer."
1060
+ else:
1061
+ tool_calls_seen += 1
1062
+ try:
1063
+ content = getattr(tools, canonical)(**args)
1064
+ except Exception as exc:
1065
+ content = f"tool error: {type(exc).__name__}: {exc}"
1066
+ msg = {
1067
+ "role": "tool",
1068
+ "tool_call_id": tcid,
1069
+ "name": name,
1070
+ "content": str(content)[: cfg.obs_limit],
1071
+ }
1072
+ messages.append(msg)
1073
+ transcript.append(msg)
1074
+ if stop:
1075
+ break
1076
+ except Exception as exc:
1077
+ error = f"{type(exc).__name__}: {exc}"
1078
+
1079
+ prediction = normalize_prediction(answer)
1080
+ result = {
1081
+ "sample_id": sample_id,
1082
+ "source_path": str(source_path),
1083
+ "staged_path": str(staged_path),
1084
+ "format": fmt,
1085
+ "prediction": prediction,
1086
+ "answer": answer,
1087
+ "error": error,
1088
+ "tools_used": tools.used_tools,
1089
+ "tool_calls": tool_calls_seen,
1090
+ "usage": usage,
1091
+ }
1092
+ if cfg.save_transcripts:
1093
+ result["transcript"] = transcript
1094
+ return result
1095
+
1096
+
1097
+ def write_outputs(results: list[dict[str, Any]], run_dir: Path) -> None:
1098
+ results_path = run_dir / "results.jsonl"
1099
+ with results_path.open("w", encoding="utf-8") as fh:
1100
+ for row in results:
1101
+ fh.write(json.dumps(row, ensure_ascii=True) + "\n")
1102
+
1103
+ counts: dict[str, int] = {}
1104
+ by_format: dict[str, dict[str, int]] = {}
1105
+ for row in results:
1106
+ pred = str(row.get("prediction", "unknown"))
1107
+ fmt = str(row.get("format", "unknown"))
1108
+ counts[pred] = counts.get(pred, 0) + 1
1109
+ by_format.setdefault(fmt, {})
1110
+ by_format[fmt][pred] = by_format[fmt].get(pred, 0) + 1
1111
+ summary = {
1112
+ "total": len(results),
1113
+ "predictions": counts,
1114
+ "by_format": by_format,
1115
+ "errors": sum(1 for row in results if row.get("error")),
1116
+ "completed_at": datetime.now(timezone.utc).isoformat(),
1117
+ }
1118
+ (run_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
1119
+
1120
+ lines = [
1121
+ "# AgentRE Triage Summary",
1122
+ "",
1123
+ f"Total samples: {len(results)}",
1124
+ f"Errors: {summary['errors']}",
1125
+ "",
1126
+ "## Predictions",
1127
+ "",
1128
+ ]
1129
+ for label, count in sorted(counts.items()):
1130
+ lines.append(f"- {label}: {count}")
1131
+ lines += ["", "## Samples", ""]
1132
+ for row in results:
1133
+ lines.append(
1134
+ f"- `{row['sample_id']}` `{row['format']}` `{row['prediction']}` "
1135
+ f"tools={row['tool_calls']} path=`{row['source_path']}`"
1136
+ )
1137
+ answer = row.get("answer")
1138
+ if isinstance(answer, dict) and answer.get("summary"):
1139
+ lines.append(f" - {answer['summary']}")
1140
+ if row.get("error"):
1141
+ lines.append(f" - error: {row['error']}")
1142
+ (run_dir / "SUMMARY.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
1143
+
1144
+
1145
+ def main() -> int:
1146
+ parser = argparse.ArgumentParser(description="Analyze PE/ELF files with the AgentRE model and static tools.")
1147
+ parser.add_argument("target", help="File or directory to analyze.")
1148
+ parser.add_argument("--spec", default=str(DEFAULT_SPEC), help="Unified reverse-engineering spec.")
1149
+ parser.add_argument(
1150
+ "--backend",
1151
+ choices=["auto", "transformers", "llama-cpp"],
1152
+ default="auto",
1153
+ help="Inference backend. auto selects llama-cpp for .gguf models, otherwise transformers.",
1154
+ )
1155
+ parser.add_argument("--model", default=DEFAULT_BASE_MODEL, help="HF base model path/name or GGUF path for llama-cpp.")
1156
+ parser.add_argument("--adapter", default=DEFAULT_ADAPTER, help="Optional HF LoRA adapter path for transformers. Use '' to disable.")
1157
+ parser.add_argument("--output-dir", default="", help="Output directory. Defaults to runs/agentre_triage_<timestamp>.")
1158
+ parser.add_argument("--max-files", type=int, default=0, help="Directory mode limit; 0 means no limit.")
1159
+ parser.add_argument("--include-unknown", action="store_true", help="Include files that are not PE/ELF by magic bytes.")
1160
+ parser.add_argument("--max-turns", type=int, default=14)
1161
+ parser.add_argument("--max-tool-calls", type=int, default=12)
1162
+ parser.add_argument("--max-tokens", type=int, default=1200)
1163
+ parser.add_argument("--obs-limit", type=int, default=5000)
1164
+ parser.add_argument("--temperature", type=float, default=0.1)
1165
+ parser.add_argument("--dtype", default="auto", help="auto, bfloat16, float16, float32.")
1166
+ parser.add_argument("--device-map", default="auto")
1167
+ parser.add_argument("--llama-cli", default=DEFAULT_LLAMA_CLI, help="Path to llama.cpp llama-cli.")
1168
+ parser.add_argument("--ctx-size", type=int, default=32768, help="llama.cpp context size.")
1169
+ parser.add_argument("--threads", type=int, default=0, help="llama.cpp CPU threads; 0 lets llama.cpp choose.")
1170
+ parser.add_argument("--gpu-layers", type=int, default=-1, help="llama.cpp GPU layers; -1 leaves default.")
1171
+ parser.add_argument("--llama-lora", default="", help="Optional llama.cpp-compatible LoRA adapter. HF PEFT LoRA is not accepted here.")
1172
+ parser.add_argument("--llama-extra-arg", action="append", default=[], help="Extra raw argument passed to llama-cli. Repeatable.")
1173
+ parser.add_argument("--no-thinking", action="store_true", help="Disable Qwen thinking template flag.")
1174
+ parser.add_argument("--save-transcripts", action="store_true")
1175
+ parser.add_argument("--save-reasoning", action="store_true", help="Only meaningful with --save-transcripts.")
1176
+ parser.add_argument("--ghidra-script-dir", default=str(DEFAULT_GHIDRA_SCRIPT_DIR))
1177
+ parser.add_argument(
1178
+ "--request",
1179
+ default="Is this malicious or benign? Reverse engineer it and use the available tools.",
1180
+ help="User request inserted for each sample.",
1181
+ )
1182
+ parser.add_argument("--tool-smoke", action="store_true", help="Run static tools on the target and exit without loading the model.")
1183
+ args = parser.parse_args()
1184
+
1185
+ target = Path(args.target).expanduser().resolve()
1186
+ targets = collect_targets(target, include_unknown=args.include_unknown, max_files=args.max_files)
1187
+ if not targets:
1188
+ print(f"No PE/ELF targets found under {target}", file=sys.stderr)
1189
+ return 2
1190
+
1191
+ if args.output_dir:
1192
+ run_dir = Path(args.output_dir).expanduser().resolve()
1193
+ else:
1194
+ run_dir = (ROOT / "runs" / f"agentre_triage_{_now_stamp()}").resolve()
1195
+ run_dir.mkdir(parents=True, exist_ok=True)
1196
+
1197
+ spec = Path(args.spec).read_text(encoding="utf-8")
1198
+ cfg = RuntimeConfig(
1199
+ max_turns=args.max_turns,
1200
+ max_tool_calls=args.max_tool_calls,
1201
+ max_tokens=args.max_tokens,
1202
+ obs_limit=args.obs_limit,
1203
+ temperature=args.temperature,
1204
+ thinking=not args.no_thinking,
1205
+ save_transcripts=args.save_transcripts,
1206
+ save_reasoning=args.save_reasoning,
1207
+ ghidra_script_dir=Path(args.ghidra_script_dir).expanduser().resolve(),
1208
+ )
1209
+
1210
+ if args.tool_smoke:
1211
+ smoke_dir = run_dir / "tool_smoke"
1212
+ smoke_dir.mkdir(parents=True, exist_ok=True)
1213
+ for idx, path in enumerate(targets, 1):
1214
+ fmt = detect_format(path)
1215
+ staged = smoke_dir / f"sample_{idx:04d}{'.elf' if fmt == 'ELF' else '.exe' if fmt == 'PE' else path.suffix}"
1216
+ shutil.copy2(path, staged)
1217
+ tools = StaticTools(staged, fmt, cfg.obs_limit, cfg.ghidra_script_dir)
1218
+ print(f"== {path} ({fmt}) ==")
1219
+ print(tools.file())
1220
+ print(tools.strings()[:1200])
1221
+ return 0
1222
+
1223
+ adapter = args.adapter.strip() or None
1224
+ backend_name = args.backend
1225
+ if backend_name == "auto":
1226
+ backend_name = "llama-cpp" if str(args.model).lower().endswith(".gguf") else "transformers"
1227
+ print(f"[xref 9b] backend={backend_name} model={args.model}", flush=True)
1228
+ if adapter and backend_name == "transformers":
1229
+ print(f"[xref 9b] adapter={adapter}", flush=True)
1230
+ elif adapter and backend_name == "llama-cpp":
1231
+ print("[xref 9b] note: --adapter is ignored by llama-cpp; use a merged GGUF or --llama-lora", flush=True)
1232
+ print(f"[xref 9b] thinking={'on' if cfg.thinking else 'off'} targets={len(targets)} output={run_dir}", flush=True)
1233
+ if backend_name == "transformers":
1234
+ backend = LocalQwenBackend(args.model, adapter, args.dtype, args.device_map)
1235
+ else:
1236
+ backend = LlamaCppBackend(
1237
+ args.model,
1238
+ args.llama_cli,
1239
+ args.ctx_size,
1240
+ args.threads,
1241
+ args.gpu_layers,
1242
+ args.llama_lora.strip() or None,
1243
+ args.llama_extra_arg,
1244
+ )
1245
+
1246
+ results: list[dict[str, Any]] = []
1247
+ for idx, path in enumerate(targets, 1):
1248
+ sample_id = f"sample_{idx:04d}"
1249
+ print(f"[xref 9b] analyzing {sample_id} {path}", flush=True)
1250
+ result = analyze_one(backend, path, sample_id, run_dir, spec, cfg, args.request)
1251
+ results.append(result)
1252
+ print(
1253
+ f"[xref 9b] {sample_id} format={result['format']} prediction={result['prediction']} "
1254
+ f"tools={result['tool_calls']} error={result['error'] or ''}",
1255
+ flush=True,
1256
+ )
1257
+ write_outputs(results, run_dir)
1258
+
1259
+ write_outputs(results, run_dir)
1260
+ print(f"[xref 9b] summary={run_dir / 'SUMMARY.md'}", flush=True)
1261
+ return 0
1262
+
1263
+
1264
+ if __name__ == "__main__":
1265
+ raise SystemExit(main())
assets/xref-9b-banner.svg ADDED
assets/xref-9b-eval-chart.svg ADDED
release_manifest.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "xref-9b",
3
+ "release_type": "research_preview_gguf",
4
+ "created_at_utc": "2026-07-14T07:31:21.401747+00:00",
5
+ "artifacts": [
6
+ {
7
+ "path": "xref-9b-q4_k_m.gguf",
8
+ "sha256": "87f320a4edda407a00ddb005774640acac4c45882a9d03674e5f2c37e9dbafd6",
9
+ "recommended": true
10
+ },
11
+ {
12
+ "path": "xref-9b-f16.gguf",
13
+ "sha256": "2e9438786b7aed9a9314629e811900616c1b4666a2f7cffa2f537fd566f238d8",
14
+ "recommended": false
15
+ }
16
+ ],
17
+ "training_summary_public": [
18
+ "SFT on PE/ELF reverse-engineering instruction traces",
19
+ "IPO preference optimization over curated PE/ELF triage pairs"
20
+ ],
21
+ "eval_summary": {
22
+ "mixed40_total": 40,
23
+ "sft_baseline_strict_accuracy": 0.55,
24
+ "sft_baseline_binary_accuracy": 0.625,
25
+ "ipo_strict_accuracy": 0.575,
26
+ "ipo_binary_accuracy": 0.575,
27
+ "ipo_release_workflow_strict_accuracy": 0.675,
28
+ "ipo_release_workflow_binary_accuracy": 0.675
29
+ },
30
+ "local_runtime_files": [
31
+ "agentre.py",
32
+ "agentre_triage.py",
33
+ "reverse_engineering_spec_unified.md",
34
+ "tools/ghidra_scripts/AgentRESummary.java"
35
+ ]
36
+ }
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # xref-9b wrapper runtime dependencies
2
+ # The core chat workflow uses Python stdlib plus local command-line tools.
3
+ # Optional HF/Transformers path may require torch, transformers, and peft.
reverse_engineering_spec_unified.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AgentRE Unified Reverse Engineering Spec
2
+
3
+ ## Purpose
4
+
5
+ You are a defensive reverse-engineering assistant. Analyze one binary with static tools, decide whether it is benign, malicious, hackware, or unknown, and submit one structured JSON verdict. The user may provide a single file or a directory of files; each analysis target is handled independently.
6
+
7
+ Do not execute the sample. Do not provide instructions for running malware. Ground every claim in tool output.
8
+
9
+ ## Labels
10
+
11
+ - `benign`: ordinary non-malicious PE or ELF software.
12
+ - `malicious`: malware or clearly malicious implant behavior such as C2, reverse shell, loader/dropper, credential theft, persistence, destructive behavior, ransomware behavior, botnet behavior, worm behavior, or anti-analysis malware behavior.
13
+ - `hackware`: offensive or dual-use intrusion tooling, including scanners, exploit tooling, payload delivery, backdoor administration, credential attack tooling, or shell-access tooling.
14
+ - `unknown`: use only when static evidence is insufficient after reasonable tool use.
15
+ - When using `unknown`, explain what evidence is missing and recommend closer inspection with Ghidra or another disassembler, especially for stripped, static, packed, encrypted, or sparse-string binaries.
16
+
17
+ ## Core Workflow
18
+
19
+ 1. Start with `file` or `run_file` to identify PE/ELF format, architecture, linkage, and stripped status.
20
+ 2. Use `strings` or `run_strings` early to find APIs, URLs, IPs, domains, paths, commands, mutexes, services, ransom notes, or suspicious blobs.
21
+ 3. Use format-specific metadata tools:
22
+ - ELF: `readelf`, `objdump`, `nm`.
23
+ - PE: `pe_headers`, `pe_sections`, `pe_imports`, `pe_exports`, `pe_symbols`, `pe_disasm`.
24
+ 4. Use disassembly before making behavior claims. Prefer targeted `disasm_func` or `run_disasm_func` when symbols name important functions.
25
+ 5. Use `entropy`, `hexdump`, or `xxd` for packed, encrypted, encoded, or blob-like data.
26
+ 6. Use `ghidra_summary` when binutils output is insufficient or when higher-level function/import/string summaries would help.
27
+ For stripped/static samples that loop on headers or have sparse strings, use `ghidra_summary` or disassembly before giving up.
28
+ 7. Submit exactly one `final_answer` JSON object.
29
+
30
+ ## Available Tools
31
+
32
+ All tools are static. Tool path arguments are optional; the runtime routes tools to the current staged sample.
33
+
34
+ Common tools:
35
+
36
+ - `file` / `run_file`: identify file type, architecture, linkage, and stripped status.
37
+ - `strings` / `run_strings`: extract printable strings.
38
+ - `entropy`: compute whole-file Shannon entropy.
39
+ - `hexdump`: dump bytes at an offset.
40
+ - `xxd`: alternate hex dump.
41
+ - `disasm_func` / `run_disasm_func`: disassemble one named function.
42
+ - `ghidra_summary`: run Ghidra headless analysis and return imports, strings, functions, and short instruction excerpts when Ghidra is installed.
43
+ - `final_answer` / `submit_answer`: submit the final JSON verdict.
44
+
45
+ ELF tools:
46
+
47
+ - `readelf` / `run_readelf`: inspect ELF headers, sections, symbols, program headers, and dynamic imports.
48
+ - `objdump` / `run_objdump`: disassemble or dump ELF sections.
49
+ - `nm`: list symbols.
50
+
51
+ PE tools:
52
+
53
+ - `pe_headers` / `run_pe_headers`: PE file header, optional header, data directories, subsystem, DLL characteristics.
54
+ - `pe_sections` / `run_pe_sections`: PE section table and entropy.
55
+ - `pe_imports` / `run_pe_imports`: imported DLLs and WinAPI symbols.
56
+ - `pe_exports` / `run_pe_exports`: export table and data directories.
57
+ - `pe_disasm` / `run_pe_disasm`: PE disassembly.
58
+ - `pe_symbols` / `run_pe_symbols`: PE symbols when present.
59
+
60
+ ## PE Evidence Hints
61
+
62
+ Malicious PE evidence includes:
63
+
64
+ - C2 or beaconing: `InternetOpen`, `InternetConnect`, `HttpSendRequest`, `WinHttp*`, `WSAStartup`, `socket`, `connect`, hardcoded domains/IPs/URLs.
65
+ - Injection: `VirtualAllocEx`, `WriteProcessMemory`, `CreateRemoteThread`, `NtCreateThreadEx`, `QueueUserAPC`, `SetThreadContext`.
66
+ - Process hollowing: suspended process creation, `NtUnmapViewOfSection`, remote allocation/write, context change, resume.
67
+ - Persistence: service APIs, Run keys, scheduled task strings, startup-folder paths.
68
+ - Ransomware: recursive file traversal, file-extension targeting, ransom notes, crypto APIs, high-entropy encrypted payloads.
69
+ - Anti-analysis: `IsDebuggerPresent`, `CheckRemoteDebuggerPresent`, `NtQueryInformationProcess`, PEB checks, sandbox DLL checks, CPUID VM checks.
70
+
71
+ ## ELF Evidence Hints
72
+
73
+ Malicious ELF evidence includes:
74
+
75
+ - C2 or botnet behavior: `socket`, `connect`, `send`, `recv`, `gethostbyname`, hardcoded domains/IPs, beacon loops.
76
+ - Shell or command execution: `/bin/sh`, `execve`, `system`, `popen`, shell command strings.
77
+ - Loader/dropper behavior: writes executable files, changes permissions with `chmod`, downloads via `wget`/`curl`, forks and execs payloads.
78
+ - Botnet/scanner behavior: attack/scanner function names, `/proc/net/tcp`, port scanning, watchdog use, single-instance locks, process killer routines.
79
+ - Anti-analysis: `ptrace`, anti-debug names, environment checks, process hiding, string/config decoding before network use.
80
+
81
+ ## Final Answer Schema
82
+
83
+ Call `final_answer` exactly once with:
84
+
85
+ ```json
86
+ {
87
+ "answer_json": {
88
+ "schema_version": "agentre_triage_v1",
89
+ "file_type": "ELF64",
90
+ "architecture": "x86-64",
91
+ "classification": "malicious",
92
+ "confidence": 0.85,
93
+ "summary": "One or two sentence verdict.",
94
+ "evidence": ["tool-grounded fact 1", "tool-grounded fact 2"],
95
+ "techniques": ["socket_connect", "xor_string_obfuscation"],
96
+ "network_indicators": [],
97
+ "decoded_strings": {},
98
+ "encoded_strings": false,
99
+ "encryption_details": {
100
+ "algorithm": null,
101
+ "key": null,
102
+ "key_storage": null
103
+ },
104
+ "anti_analysis": [],
105
+ "important_functions": []
106
+ }
107
+ }
108
+ ```
109
+
110
+ Rules:
111
+
112
+ - `classification` must be one of `benign`, `malicious`, `hackware`, `unknown`.
113
+ - Use JSON `null`, `false`, empty lists, or empty objects for absent findings.
114
+ - Keep evidence concise and tied to observed tool output.
115
+ - Do not include chain-of-thought in the final answer.
scripts/check_tools.sh ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ for tool in file strings readelf objdump nm; do
5
+ if command -v "$tool" >/dev/null 2>&1; then
6
+ echo "ok: $tool -> $(command -v "$tool")"
7
+ else
8
+ echo "missing: $tool"
9
+ fi
10
+ done
11
+
12
+ if command -v hexdump >/dev/null 2>&1; then
13
+ echo "ok: hexdump -> $(command -v hexdump)"
14
+ elif command -v xxd >/dev/null 2>&1; then
15
+ echo "ok: xxd -> $(command -v xxd)"
16
+ else
17
+ echo "missing: hexdump or xxd"
18
+ fi
19
+
20
+ if [[ -n "${GHIDRA_HEADLESS:-}" && -x "${GHIDRA_HEADLESS:-}" ]]; then
21
+ echo "ok: GHIDRA_HEADLESS -> $GHIDRA_HEADLESS"
22
+ elif command -v analyzeHeadless >/dev/null 2>&1; then
23
+ echo "ok: analyzeHeadless -> $(command -v analyzeHeadless)"
24
+ else
25
+ echo "optional missing: Ghidra analyzeHeadless"
26
+ fi
scripts/run_xref9b_chat.sh ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ $# -lt 1 ]]; then
5
+ echo "usage: $0 /path/to/binary [extra agentre.py args...]" >&2
6
+ exit 2
7
+ fi
8
+
9
+ TARGET="$1"
10
+ shift
11
+
12
+ MODEL="${XREF9B_GGUF:-$PWD/xref-9b-q4_k_m.gguf}"
13
+ LLAMA_CLI="${XREF9B_LLAMA_CLI:-llama-completion}"
14
+
15
+ python3 agentre.py chat "$TARGET" --model "$MODEL" --llama-cli "$LLAMA_CLI" --ctx-size "${XREF9B_CTX_SIZE:-65536}" --gpu-layers "${XREF9B_GPU_LAYERS:-99}" --max-turns "${XREF9B_MAX_TURNS:-8}" --max-tool-calls "${XREF9B_MAX_TOOL_CALLS:-16}" --max-tokens "${XREF9B_MAX_TOKENS:-1600}" --obs-limit "${XREF9B_OBS_LIMIT:-8000}" "$@"
scripts/run_xref9b_triage.sh ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ $# -lt 1 ]]; then
5
+ echo "usage: $0 /path/to/file_or_directory [extra agentre.py triage args...]" >&2
6
+ exit 2
7
+ fi
8
+
9
+ TARGET="$1"
10
+ shift
11
+
12
+ MODEL="${XREF9B_GGUF:-$PWD/xref-9b-q4_k_m.gguf}"
13
+ LLAMA_CLI="${XREF9B_LLAMA_CLI:-llama-completion}"
14
+
15
+ python3 agentre.py triage "$TARGET" --model "$MODEL" --llama-cli "$LLAMA_CLI" --ctx-size "${XREF9B_CTX_SIZE:-65536}" --gpu-layers "${XREF9B_GPU_LAYERS:-99}" --max-tool-calls "${XREF9B_MAX_TOOL_CALLS:-16}" --max-tokens "${XREF9B_MAX_TOKENS:-1600}" --obs-limit "${XREF9B_OBS_LIMIT:-8000}" "$@"
scripts/xref9b.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Copy to scripts/xref9b.env and edit paths for your machine.
2
+ export XREF9B_GGUF="$PWD/xref-9b-q4_k_m.gguf"
3
+ export XREF9B_LLAMA_CLI="/path/to/llama.cpp/build/bin/llama-completion"
4
+
5
+ # Optional. Required for /ghidra and ghidra_summary.
6
+ export GHIDRA_HEADLESS="/path/to/ghidra/support/analyzeHeadless"
tools/ghidra_scripts/AgentRESummary.java ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Static Ghidra headless summary script for AgentRE.
2
+ // Usage from analyzeHeadless:
3
+ // -postScript AgentRESummary.java /path/to/output.txt
4
+
5
+ import ghidra.app.script.GhidraScript;
6
+ import ghidra.program.model.address.Address;
7
+ import ghidra.program.model.listing.Data;
8
+ import ghidra.program.model.listing.DataIterator;
9
+ import ghidra.program.model.listing.Function;
10
+ import ghidra.program.model.listing.FunctionIterator;
11
+ import ghidra.program.model.listing.Instruction;
12
+ import ghidra.program.model.listing.InstructionIterator;
13
+ import ghidra.program.model.listing.Listing;
14
+ import ghidra.program.model.mem.MemoryBlock;
15
+ import ghidra.program.model.symbol.Symbol;
16
+ import ghidra.program.model.symbol.SymbolIterator;
17
+
18
+ import java.io.File;
19
+ import java.io.FileWriter;
20
+ import java.io.PrintWriter;
21
+
22
+ public class AgentRESummary extends GhidraScript {
23
+ private String clean(String s) {
24
+ if (s == null) {
25
+ return "";
26
+ }
27
+ s = s.replace('\n', ' ').replace('\r', ' ');
28
+ if (s.length() > 240) {
29
+ return s.substring(0, 240) + "...";
30
+ }
31
+ return s;
32
+ }
33
+
34
+ public void run() throws Exception {
35
+ String[] args = getScriptArgs();
36
+ if (args.length < 1) {
37
+ println("AgentRESummary requires an output path argument");
38
+ return;
39
+ }
40
+
41
+ File outFile = new File(args[0]);
42
+ try (PrintWriter out = new PrintWriter(new FileWriter(outFile))) {
43
+ Listing listing = currentProgram.getListing();
44
+
45
+ out.println("== Program ==");
46
+ out.println("name: " + currentProgram.getName());
47
+ out.println("format: " + currentProgram.getExecutableFormat());
48
+ out.println("language: " + currentProgram.getLanguageID());
49
+ out.println("compiler: " + currentProgram.getCompilerSpec().getCompilerSpecID());
50
+ out.println("image_base: " + currentProgram.getImageBase());
51
+
52
+ out.println();
53
+ out.println("== Memory Blocks ==");
54
+ for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
55
+ out.println(block.getName()
56
+ + " start=" + block.getStart()
57
+ + " end=" + block.getEnd()
58
+ + " size=" + block.getSize()
59
+ + " r=" + block.isRead()
60
+ + " w=" + block.isWrite()
61
+ + " x=" + block.isExecute());
62
+ }
63
+
64
+ out.println();
65
+ out.println("== External Symbols / Imports ==");
66
+ int count = 0;
67
+ SymbolIterator symbols = currentProgram.getSymbolTable().getExternalSymbols();
68
+ while (symbols.hasNext() && count < 600) {
69
+ Symbol sym = symbols.next();
70
+ out.println(sym.getName(true));
71
+ count++;
72
+ }
73
+ if (count >= 600) {
74
+ out.println("[truncated imports at 600]");
75
+ }
76
+
77
+ out.println();
78
+ out.println("== Strings ==");
79
+ count = 0;
80
+ DataIterator data = listing.getDefinedData(true);
81
+ while (data.hasNext() && count < 500) {
82
+ Data d = data.next();
83
+ Object value = null;
84
+ try {
85
+ value = d.getValue();
86
+ } catch (Throwable ignored) {
87
+ value = null;
88
+ }
89
+ if (value instanceof String) {
90
+ String text = clean((String) value);
91
+ if (text.length() >= 4) {
92
+ out.println(d.getAddress() + " " + text);
93
+ count++;
94
+ }
95
+ }
96
+ }
97
+ if (count >= 500) {
98
+ out.println("[truncated strings at 500]");
99
+ }
100
+
101
+ out.println();
102
+ out.println("== Functions ==");
103
+ count = 0;
104
+ FunctionIterator funcs = listing.getFunctions(true);
105
+ while (funcs.hasNext() && count < 220) {
106
+ Function f = funcs.next();
107
+ out.println(f.getEntryPoint() + " " + f.getName(true));
108
+ count++;
109
+ }
110
+ if (count >= 220) {
111
+ out.println("[truncated functions at 220]");
112
+ }
113
+
114
+ out.println();
115
+ out.println("== Function Instruction Excerpts ==");
116
+ int funcsPrinted = 0;
117
+ funcs = listing.getFunctions(true);
118
+ while (funcs.hasNext() && funcsPrinted < 40) {
119
+ Function f = funcs.next();
120
+ String name = f.getName(true).toLowerCase();
121
+ boolean interesting = name.contains("main")
122
+ || name.contains("connect")
123
+ || name.contains("socket")
124
+ || name.contains("send")
125
+ || name.contains("recv")
126
+ || name.contains("exec")
127
+ || name.contains("crypt")
128
+ || name.contains("decrypt")
129
+ || name.contains("attack")
130
+ || name.contains("scan")
131
+ || name.contains("loader")
132
+ || name.contains("start");
133
+ if (!interesting && funcsPrinted >= 12) {
134
+ continue;
135
+ }
136
+ out.println();
137
+ out.println("-- " + f.getEntryPoint() + " " + f.getName(true) + " --");
138
+ InstructionIterator instrs = listing.getInstructions(f.getBody(), true);
139
+ int insnCount = 0;
140
+ while (instrs.hasNext() && insnCount < 45) {
141
+ Instruction insn = instrs.next();
142
+ Address addr = insn.getAddress();
143
+ out.println(" " + addr + " " + insn.toString());
144
+ insnCount++;
145
+ }
146
+ funcsPrinted++;
147
+ }
148
+ }
149
+
150
+ println("AgentRE summary written to " + outFile.getAbsolutePath());
151
+ }
152
+ }
xref-9b-f16.gguf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2e9438786b7aed9a9314629e811900616c1b4666a2f7cffa2f537fd566f238d8
3
+ size 17920696640
xref-9b-q4_k_m.gguf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:87f320a4edda407a00ddb005774640acac4c45882a9d03674e5f2c37e9dbafd6
3
+ size 5629108544