use std::sync::{Arc, Mutex};
use crate::mcp::sanitize::{sanitize_to_mcp_error, ErrorCategory};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
AnnotateAble, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
ListResourcesResult, PaginatedRequestParams, RawResource, ReadResourceRequestParams,
ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo,
};
use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
use tokio_util::sync::CancellationToken;
use crate::context::CrossLayerContext;
use crate::mcp::concurrency::{ConcurrencyConfig, ToolClass};
pub mod classify;
pub mod concurrency;
pub mod prompts;
pub mod resources;
pub mod sanitize;
pub mod subprocess;
pub struct DroidsawServer {
state: Arc<Mutex<Option<CrossLayerContext>>>,
current_db: Arc<Mutex<Option<std::path::PathBuf>>>,
concurrency: Arc<ConcurrencyConfig>,
tool_router: ToolRouter<Self>,
allowed_tool_classes: std::collections::BTreeSet<McpToolClass>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum McpToolClass {
ReadOnly,
WritesTempfile,
WritesCallerPath,
SpawnsSubprocess,
ManagesState,
}
impl McpToolClass {
pub fn as_kebab(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WritesTempfile => "writes-tempfile",
Self::WritesCallerPath => "writes-caller-path",
Self::SpawnsSubprocess => "spawns-subprocess",
Self::ManagesState => "manages-state",
}
}
pub fn default_allowed() -> std::collections::BTreeSet<Self> {
let mut set = std::collections::BTreeSet::new();
set.insert(Self::ReadOnly);
set.insert(Self::WritesTempfile);
set
}
pub fn all() -> [Self; 5] {
[
Self::ReadOnly,
Self::WritesTempfile,
Self::WritesCallerPath,
Self::SpawnsSubprocess,
Self::ManagesState,
]
}
}
impl std::str::FromStr for McpToolClass {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim() {
"read-only" => Ok(Self::ReadOnly),
"writes-tempfile" => Ok(Self::WritesTempfile),
"writes-caller-path" => Ok(Self::WritesCallerPath),
"spawns-subprocess" => Ok(Self::SpawnsSubprocess),
"manages-state" => Ok(Self::ManagesState),
other => Err(format!(
"unknown tool class '{other}'; valid: {}",
Self::all()
.iter()
.map(|c| c.as_kebab())
.collect::<Vec<_>>()
.join(", ")
)),
}
}
}
pub fn tool_class(name: &str) -> McpToolClass {
match name {
"load" => McpToolClass::WritesTempfile,
"manifest" | "signing" | "info" | "query" | "investigate" | "taint"
| "strings" | "xrefs" | "frida" | "decompile" | "diff"
| "apk_decompile" | "hbc_info" | "hbc_functions" | "dex_classes" | "dex_methods" | "module_list" | "native_modules" | "disasm" | "npm_packages" | "call_graph" | "apk_entries" | "apk_elf" | "apk_webview_assets" | "apk_resources" | "apk_sbom" | "apk_scan_corpus" => McpToolClass::ReadOnly,
"audit" => McpToolClass::SpawnsSubprocess,
"triage" => McpToolClass::ManagesState,
"apk_export" | "corpus_ingest" | "apk_semgrep_extract" | "apk_trufflehog" => McpToolClass::WritesCallerPath,
"apk_yara" => McpToolClass::SpawnsSubprocess,
_ => McpToolClass::ManagesState,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum McpToolTier {
Basic,
Full,
}
impl std::str::FromStr for McpToolTier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim() {
"basic" => Ok(Self::Basic),
"full" => Ok(Self::Full),
other => Err(format!(
"unknown tool tier '{other}'; valid: basic, full"
)),
}
}
}
pub const BASIC_TIER_TOOLS: &[&str] = &[
"load",
"info",
"manifest",
"signing",
"audit",
"query",
"investigate",
"taint",
"triage",
"decompile",
"strings",
"xrefs",
];
fn tool_tier(name: &str) -> McpToolTier {
if BASIC_TIER_TOOLS.contains(&name) {
McpToolTier::Basic
} else {
McpToolTier::Full
}
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct LoadFileParams {
pub path: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct NoParams {}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct StringsParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub min_length: Option<usize>,
#[serde(default = "default_strings_limit")]
pub limit: Option<usize>,
#[serde(default)]
pub layer: Option<String>,
}
fn default_strings_limit() -> Option<usize> {
Some(200)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
#[serde(default)]
pub search: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct XrefsParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FridaParams {
pub search: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DecompileParams {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub js: bool,
#[serde(default)]
pub all: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ManifestParams {
#[serde(default)]
pub strict: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AuditFullParams {
#[serde(default = "default_entropy")]
pub entropy: f32,
pub output: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub update_db: Option<bool>,
}
fn generate_investigation_leads(
obj: &serde_json::Map<String, serde_json::Value>,
db_path: &str,
) -> serde_json::Value {
let taint_count = obj.get("taint_flow_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let finding_count = obj.get("finding_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let semgrep_ran = obj.get("semgrep")
.and_then(|v| {
#[allow(
clippy::indexing_slicing,
reason = "serde_json::Value indexing returns Null on miss, not panic"
)]
v["semgrep_scan"]["ran"].as_bool()
})
.unwrap_or(false);
let mut views: Vec<serde_json::Value> = vec![
serde_json::json!({"view": "audit_summary", "description": "One-row summary: counts of findings, taint flows, secrets, semgrep hits, xrefs"}),
serde_json::json!({"view": "actionable_findings", "description": "Critical/High semantic findings (noise filtered)"}),
serde_json::json!({"view": "finding_context", "description": "Findings joined with their xrefs — what strings do flagged classes reference?"}),
serde_json::json!({"view": "finding_urls", "description": "URLs and deep links referenced by classes with findings"}),
];
if taint_count > 0 {
views.push(serde_json::json!({"view": "taint_critical", "description": "High/Critical taint flows with source→sink types"}));
}
if semgrep_ran {
views.push(serde_json::json!({"view": "semgrep_hotspots", "description": "Classes with most semgrep findings + which rules hit"}));
}
let mut finding_prompts: Vec<serde_json::Value> = Vec::new();
let semgrep_note = if semgrep_ran { ", semgrep results persisted" } else { "" };
finding_prompts.push(serde_json::json!({
"scope": "general",
"prompt": format!("\
{finding_count} findings, {taint_count} taint flows{semgrep_note}. Audit DB at {db_path}.
Orient with SELECT * FROM audit_summary, then SELECT * FROM actionable_findings. Views are pre-built — see the views list. Use investigate, decompile, xrefs, and manifest to investigate. Filter on gauge_class='Semantic' to skip noise."),
}));
if !db_path.is_empty()
&& let Ok(db) = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
{
let sql = "\
SELECT rowid, severity, id_tag, detail FROM findings \
WHERE severity IN ('Critical', 'High', 'Medium') \
AND gauge_class = 'Semantic' \
GROUP BY severity, id_tag \
HAVING rowid = MIN(rowid) \
ORDER BY CASE severity \
WHEN 'Critical' THEN 0 WHEN 'High' THEN 1 WHEN 'Medium' THEN 2 \
END, rowid";
if let Ok(mut stmt) = db.prepare(sql)
&& let Ok(mut rows) = stmt.query([])
{
let mut high_count = 0usize;
let mut medium_count = 0usize;
while let Ok(Some(row)) = rows.next() {
let rowid: i64 = row.get(0).unwrap_or(0);
let severity: String = row.get(1).unwrap_or_default();
let id_tag: String = row.get(2).unwrap_or_default();
let detail: String = row.get(3).unwrap_or_default();
match severity.as_str() {
"Critical" => {}
"High" => {
if high_count >= 3 { continue; }
high_count = high_count.saturating_add(1);
}
"Medium" => {
if medium_count >= 3 { continue; }
medium_count = medium_count.saturating_add(1);
}
_ => continue,
}
let siblings = db.query_row(
"SELECT COUNT(*) FROM findings WHERE id_tag = ?1 AND severity = ?2",
rusqlite::params![&id_tag, &severity],
|r| r.get::<_, i64>(0),
).unwrap_or(1);
let plural = if siblings > 1 { "s" } else { "" };
finding_prompts.push(serde_json::json!({
"scope": "finding",
"finding_rowid": rowid,
"severity": severity,
"id_tag": id_tag,
"total_with_this_tag": siblings,
"prompt": format!("\
Investigate {severity} finding: {id_tag} ({siblings} instance{plural})
Representative: #{rowid} — {detail}
Determine whether this is a true positive or noise (e.g., YARA pattern matching a binary asset vs. actual crypto misuse in code). If real, assess the impact and what an attacker could do with it. Use decompile to read the source, plus xrefs, finding_xrefs, and manifest to build context."),
}));
}
}
}
let mut leads = serde_json::Map::new();
leads.insert("views".into(), serde_json::json!(views));
leads.insert("finding_prompts".into(), serde_json::json!(finding_prompts));
leads.insert("db_path".into(), serde_json::json!(db_path));
serde_json::Value::Object(leads)
}
fn default_entropy() -> f32 {
4.5
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ExportParams {
pub output: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DisasmParams {
pub func_id: u32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CallGraphParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DexDecompileParams {
#[serde(default)]
pub class_index: Option<usize>,
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub methods: Option<Vec<String>>,
#[serde(default)]
pub dry_run: Option<bool>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DiffParams {
pub path: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CorpusIngestParams {
pub dir: String,
pub output: String,
#[serde(default)]
pub tag: Option<String>,
#[serde(default = "default_true")]
pub skip_existing: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct YaraParams {
#[serde(default)]
pub rules_src: Option<String>,
#[serde(default)]
pub rules: Option<String>,
#[serde(default = "default_yara_target")]
pub target: String,
#[serde(default)]
pub limit: Option<usize>,
}
fn default_yara_target() -> String {
"all".into()
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SemgrepParams {
#[serde(default)]
pub output: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TrufflehogParams {
#[serde(default = "default_trufflehog_min_length")]
pub min_length: usize,
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub output: Option<String>,
}
fn default_trufflehog_min_length() -> usize {
8
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ScanCorpusParams {
pub paths: Vec<String>,
#[serde(default = "default_min_severity")]
pub min_severity: String,
}
fn default_min_severity() -> String {
"info".into()
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct EntriesParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ElfParams {
#[serde(default)]
pub search: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ResourcesParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct WebviewAssetsParams {
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub extract: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DbQueryParams {
#[serde(default)]
pub db_path: Option<String>,
pub sql: String,
#[serde(default = "default_db_query_limit")]
pub limit: usize,
}
fn default_db_query_limit() -> usize {
200
}
const PRAGMA_ALLOWLIST: &[&str] = &["table_info", "table_xinfo", "index_list", "foreign_key_list"];
const MCP_LOAD_DEFAULT_BUDGET_BYTES: usize = 100 * 1024 * 1024;
pub fn is_allowed_query_sql(sql: &str) -> Result<(), &'static str> {
let trimmed = sql.trim_start();
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("select") {
return Ok(());
}
if let Some(rest) = lower.strip_prefix("pragma") {
let after_kw = match rest.chars().next() {
Some(c) if c.is_ascii_whitespace() => rest.trim_start(),
_ => return Err("only SELECT statements and curated read-only PRAGMAs are permitted"),
};
if after_kw.contains('=') {
return Err("PRAGMA assignment form (PRAGMA <name> = <value>) is not permitted");
}
let lparen = after_kw
.find('(')
.ok_or("PRAGMA must use parenthesized form: PRAGMA <name>(<arg>)")?;
#[allow(
clippy::string_slice,
reason = "lparen is a char-boundary index returned by str::find"
)]
let name = after_kw[..lparen].trim();
if !PRAGMA_ALLOWLIST.contains(&name) {
return Err(
"PRAGMA name is not on the allowlist (allowed: table_info, table_xinfo, index_list, foreign_key_list)",
);
}
let after_lparen_start = lparen.saturating_add(1);
let after_lparen = after_kw
.get(after_lparen_start..)
.ok_or("PRAGMA must use parenthesized form: PRAGMA <name>(<arg>)")?;
let rparen = after_lparen
.rfind(')')
.ok_or("PRAGMA must use parenthesized form: PRAGMA <name>(<arg>)")?;
let arg = after_lparen
.get(..rparen)
.ok_or("PRAGMA must use parenthesized form: PRAGMA <name>(<arg>)")?
.trim();
if arg.is_empty() {
return Err("PRAGMA argument must be non-empty");
}
let after_rparen_start = rparen.saturating_add(1);
let trailing = after_lparen.get(after_rparen_start..).unwrap_or("");
if !trailing.trim().is_empty() {
return Err("trailing content after PRAGMA <name>(<arg>) is not permitted");
}
return Ok(());
}
Err("only SELECT statements and curated read-only PRAGMAs are permitted")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathRole {
LoadInput,
Database,
LoadDirectory,
LoadInputOrDirectory,
WriteOutput,
}
const READ_FORBIDDEN: &[&str] = &[
"/proc/",
"/sys/",
"/dev/",
"/run/secrets/",
"/etc/",
"/private/etc/",
];
const WRITE_EXTRA_FORBIDDEN: &[&str] = &[
"/var/lib/",
"/private/var/lib/",
"/usr/bin/",
"/usr/sbin/",
"/bin/",
"/sbin/",
];
pub fn is_allowed_path(p: &str, role: PathRole) -> Result<std::path::PathBuf, McpError> {
if p.is_empty() {
return Err(McpError::invalid_params("path is empty", None));
}
if p.contains('\0') {
return Err(McpError::invalid_params(
"path contains NUL byte",
None,
));
}
let path = std::path::PathBuf::from(p);
let canon = match role {
PathRole::WriteOutput => {
let parent = path.parent().ok_or_else(|| {
McpError::invalid_params(
"write path has no parent directory component",
None,
)
})?;
let name = path.file_name().ok_or_else(|| {
McpError::invalid_params(
"write path has no file-name component",
None,
)
})?;
let parent_canon = if parent.as_os_str().is_empty() {
std::fs::canonicalize(".").map_err(|e| {
McpError::invalid_params(
format!("write path parent (cwd) canonicalization failed: {e}"),
None,
)
})?
} else {
std::fs::canonicalize(parent).map_err(|e| {
McpError::invalid_params(
format!("write path parent canonicalization failed: {e}"),
None,
)
})?
};
parent_canon.join(name)
}
_ => std::fs::canonicalize(&path).map_err(|e| {
McpError::invalid_params(
format!("path canonicalization failed (missing entry or access denied): {e}"),
None,
)
})?,
};
let canon_str = canon.to_str().ok_or_else(|| {
McpError::invalid_params(
"canonicalized path contains non-UTF-8 bytes",
None,
)
})?;
let explicit_allow_env = if matches!(role, PathRole::WriteOutput) {
"DROIDSAW_MCP_ALLOWED_WRITE_PATH"
} else {
"DROIDSAW_MCP_ALLOWED_READ_PATH"
};
let explicitly_allowed = std::env::var(explicit_allow_env)
.ok()
.filter(|s| !s.is_empty())
.is_some_and(|allow| {
allow.split(':').any(|p| {
!p.is_empty()
&& std::fs::canonicalize(p)
.ok()
.and_then(|c| c.to_str().map(|s| canon_str.starts_with(s)))
.unwrap_or(false)
})
});
if !explicitly_allowed {
let base = READ_FORBIDDEN.iter();
let extra: &[&str] = if matches!(role, PathRole::WriteOutput) {
WRITE_EXTRA_FORBIDDEN
} else {
&[]
};
for prefix in base.chain(extra.iter()) {
if canon_str.starts_with(prefix) {
return Err(McpError::invalid_params(
format!(
"path resolves into restricted system tree {} (canonicalized form withheld)",
prefix
),
None,
));
}
}
}
match role {
PathRole::LoadInput | PathRole::Database => {
if !canon.is_file() {
return Err(McpError::invalid_params(
"path does not refer to a regular file",
None,
));
}
}
PathRole::LoadDirectory => {
if !canon.is_dir() {
return Err(McpError::invalid_params(
"path does not refer to a directory",
None,
));
}
}
PathRole::LoadInputOrDirectory => {
if !canon.is_file() && !canon.is_dir() {
return Err(McpError::invalid_params(
"path does not refer to a regular file or directory",
None,
));
}
}
PathRole::WriteOutput => {
}
}
if let Ok(root) = std::env::var("DROIDSAW_MCP_ROOT")
&& !root.is_empty()
{
let root_canon = std::fs::canonicalize(&root).map_err(|e| {
McpError::invalid_params(
format!("DROIDSAW_MCP_ROOT canonicalization failed: {e}"),
None,
)
})?;
if !canon.starts_with(&root_canon) {
return Err(McpError::invalid_params(
"path is outside the configured DROIDSAW_MCP_ROOT",
None,
));
}
}
Ok(canon)
}
#[inline]
pub fn is_allowed_load_path(p: &str, role: PathRole) -> Result<std::path::PathBuf, McpError> {
is_allowed_path(p, role)
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TriageParams {
#[serde(default)]
pub db_path: Option<String>,
pub rowid: i64,
pub action: String,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FindingContextParams {
#[serde(default)]
pub db_path: Option<String>,
#[serde(default)]
pub rowid: Option<i64>,
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub decompile: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TaintFlowsParams {
#[serde(default)]
pub db_path: Option<String>,
#[serde(default)]
pub source_type: Option<String>,
#[serde(default)]
pub sink_type: Option<String>,
}
pub fn build_taint_where_clause<'a>(
source: Option<&'a str>,
sink: Option<&'a str>,
) -> (&'static str, Vec<&'a str>) {
match (source, sink) {
(Some(s), Some(t)) => (
"WHERE source_type = ?1 AND sink_type = ?2",
vec![s, t],
),
(Some(s), None) => ("WHERE source_type = ?1", vec![s]),
(None, Some(t)) => ("WHERE sink_type = ?1", vec![t]),
(None, None) => ("", vec![]),
}
}
fn run_core_audit_blocking(
state: Arc<Mutex<Option<CrossLayerContext>>>,
_concurrency: Arc<ConcurrencyConfig>,
mode: droidsaw_cli_contract::AuditMode,
entropy: f32,
update_db: bool,
abort: crate::mcp::subprocess::AbortFlag,
) -> Result<serde_json::Map<String, serde_json::Value>, McpError> {
let check_abort = |phase: &'static str| -> Result<(), McpError> {
if abort.load(std::sync::atomic::Ordering::Relaxed) {
return Err(McpError::new(
rmcp::model::ErrorCode(-32000),
format!("audit cancelled before {phase}: client disconnected"),
Some(serde_json::json!({"type": "Cancelled", "phase": phase})),
));
}
Ok(())
};
check_abort("audit-prelude")?;
let t_core_start = std::time::Instant::now();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let db_path = if update_db {
let hash = {
let guard = state.lock().unwrap_or_else(|e| e.into_inner());
guard.as_ref().map(|c| {
CrossLayerContext::hash_path(std::path::Path::new(&c.path))
})
};
match hash {
Some(h) => std::env::temp_dir().join(format!("droidsaw-audit-{h}.db")),
None => std::env::temp_dir().join(format!("droidsaw-audit-{ts}.db")),
}
} else {
std::env::temp_dir().join(format!("droidsaw-audit-{ts}.db"))
};
let run_id = format!("{}-{ts}", mode.as_cli_str());
let t_findings = std::time::Instant::now();
let (severity_summary, top_findings, total, findings_persisted, taint_count, finding_xrefs_written, severity_by_gauge, apk_summary) = {
let guard = state.lock().unwrap_or_else(|e| e.into_inner());
let ctx = guard
.as_ref()
.ok_or_else(|| McpError::invalid_params("no file loaded — call load first", None))?;
let hash = CrossLayerContext::hash_path(std::path::Path::new(&ctx.path));
let findings_result = droidsaw_common::diag::with_input_hash(&hash, || -> anyhow::Result<_> {
let findings = crate::commands::collect_findings(ctx, entropy)?;
let total = findings.len();
let taint_count =
crate::commands::audit_envelope::AuditEnvelope::count_taint_flow_findings(
&findings,
);
let findings_persisted: usize = findings
.iter()
.map(crate::commands::finding_signature_hash)
.collect::<std::collections::BTreeSet<_>>()
.len();
crate::commands::write_findings_db_with_run(
&findings,
&db_path,
Some(&run_id),
Some(mode.as_cli_str()),
update_db,
)?;
crate::commands::write_taint_flows_db(&findings, &db_path)?;
crate::commands::write_cross_layer_taint_flows_db(&findings, &db_path)?;
let mut counts = std::collections::BTreeMap::<String, usize>::new();
for f in &findings {
let c = counts.entry(format!("{:?}", f.severity)).or_insert(0);
*c = c.saturating_add(1);
}
let severity_by_gauge =
crate::commands::audit_envelope::AuditEnvelope::stratify_by_gauge(&findings);
let top = crate::commands::audit_envelope::AuditEnvelope::rank_top_findings(
&findings,
crate::commands::audit_envelope::TOP_FINDINGS_CAP,
);
let finding_xrefs_written =
crate::commands::write_finding_xrefs_db(ctx, &findings, &db_path).unwrap_or(0);
Ok((counts, top, total, findings_persisted, taint_count, finding_xrefs_written, severity_by_gauge))
})
.map_err(|e| sanitize_to_mcp_error("audit findings", &e, ErrorCategory::InternalError))?;
let apk_summary: Option<crate::commands::audit_envelope::ApkSummary> = {
let has_hbc = ctx.hbc.is_some();
let hbc_bytes: u64 = ctx
.hbc
.as_ref()
.map(|h| h.bytes().len())
.unwrap_or(0)
.try_into()
.unwrap_or(u64::MAX);
let hbc_function_count: u32 = ctx
.hbc
.as_ref()
.map(|h| h.hbc().function_count)
.unwrap_or(0);
let dex_methods_total: u64 = ctx.dex.iter().fold(0u64, |acc, df| {
let per_dex: u64 = df.class_datas.values().fold(0u64, |a, cd| {
a.saturating_add(cd.direct_methods.len() as u64)
.saturating_add(cd.virtual_methods.len() as u64)
});
acc.saturating_add(per_dex)
});
let dex_classes_total: u64 = ctx
.dex
.iter()
.fold(0u64, |acc, df| acc.saturating_add(df.class_defs.len() as u64));
if let Some(apk) = ctx.apk.as_ref() {
let dex_count = apk.dex.len().try_into().unwrap_or(u32::MAX);
let dex_total_bytes: u64 = apk
.dex
.iter()
.fold(0u64, |acc, entry| acc.saturating_add(entry.data.len() as u64));
Some(crate::commands::audit_envelope::ApkSummary {
has_hbc,
hbc_bytes,
hbc_function_count,
dex_count,
dex_total_bytes,
dex_methods_total,
dex_classes_total,
})
} else if !ctx.dex.is_empty() {
let dex_count = ctx.dex.len().try_into().unwrap_or(u32::MAX);
let dex_total_bytes: u64 = ctx
.dex_direct_bytes
.as_ref()
.map(|b| b.len() as u64)
.unwrap_or(0);
Some(crate::commands::audit_envelope::ApkSummary {
has_hbc,
hbc_bytes,
hbc_function_count,
dex_count,
dex_total_bytes,
dex_methods_total,
dex_classes_total,
})
} else if has_hbc {
Some(crate::commands::audit_envelope::ApkSummary {
has_hbc,
hbc_bytes,
hbc_function_count,
dex_count: 0,
dex_total_bytes: 0,
dex_methods_total: 0,
dex_classes_total: 0,
})
} else {
None
}
};
let (severity_summary, top_findings, total, findings_persisted, taint_count, finding_xrefs_written, severity_by_gauge) = findings_result;
(severity_summary, top_findings, total, findings_persisted, taint_count, finding_xrefs_written, severity_by_gauge, apk_summary)
};
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: Instant::elapsed().as_millis() returns u128 measuring wall-clock since `t_findings`. The (u128 -> u64) truncation only matters once the elapsed exceeds u64::MAX ms (~584 million years). The value surfaces as `collect_findings` in timings_ms — an operator metric."
)]
let collect_findings_ms = t_findings.elapsed().as_millis() as u64;
let mut db_queries_map = serde_json::json!({
"all_high": "SELECT severity,id_tag,detail FROM findings WHERE severity IN ('Critical','High') ORDER BY severity",
"semantic_only": "SELECT severity,id_tag,detail FROM findings WHERE gauge_class='Semantic' AND severity IN ('Critical','High') ORDER BY severity",
"fts_search": "SELECT detail FROM findings_fts WHERE findings_fts MATCH 'secret OR token OR key' ORDER BY rank",
"by_severity": "SELECT severity, COUNT(*) FROM findings GROUP BY severity ORDER BY COUNT(*) DESC",
"by_gauge": "SELECT gauge_class, COUNT(*) FROM findings GROUP BY gauge_class",
"taint_flows": "SELECT source_type,sink_type,func_id,severity,cwe FROM taint_flows ORDER BY CASE severity WHEN 'Critical' THEN 0 WHEN 'High' THEN 1 ELSE 2 END",
"taint_fts": "SELECT source_type,sink_type FROM taint_flows_fts WHERE taint_flows_fts MATCH 'SqlExecute OR RuntimeExec OR WebView' ORDER BY rank",
});
if let Some(q) = db_queries_map.as_object_mut() {
q.insert(
"finding_xrefs".into(),
serde_json::json!("SELECT f.severity, f.id_tag, f.detail, fx.string_value, fx.function_name FROM findings f JOIN finding_xrefs fx ON f.rowid = fx.finding_rowid ORDER BY f.severity, fx.string_value"),
);
q.insert(
"finding_xrefs_fts".into(),
serde_json::json!("SELECT string_value, function_name FROM finding_xrefs_fts WHERE finding_xrefs_fts MATCH 'token OR oauth OR secret' ORDER BY rank"),
);
q.insert(
"semgrep_high".into(),
serde_json::json!("SELECT check_id, class_name, severity, message, cwe FROM semgrep_results WHERE severity='ERROR' ORDER BY check_id"),
);
q.insert(
"semgrep_by_class".into(),
serde_json::json!("SELECT class_name, COUNT(*) as n FROM semgrep_results GROUP BY class_name ORDER BY n DESC LIMIT 20"),
);
q.insert(
"semgrep_taint_join".into(),
serde_json::json!("SELECT sr.check_id, sr.class_name, sr.message, tf.source_type, tf.sink_type, tf.severity FROM semgrep_results sr JOIN taint_flows tf ON sr.class_name LIKE '%' || REPLACE(REPLACE(SUBSTR(tf.source_type, 1, INSTR(tf.source_type, '→')-1), 'L', ''), '/', '.') || '%' LIMIT 20"),
);
q.insert(
"credentials_verified".into(),
serde_json::json!("SELECT detector,raw,extra FROM credentials WHERE verified=1"),
);
q.insert(
"credentials_all".into(),
serde_json::json!("SELECT detector,raw,verified FROM credentials ORDER BY verified DESC LIMIT 20"),
);
}
check_abort("trufflehog-phase")?;
let (trufflehog_extract_ms, trufflehog_subprocess_ms, trufflehog_result) =
if mode.runs_trufflehog() {
let t_th_start = std::time::Instant::now();
let strings_file_handle = tempfile::Builder::new()
.prefix("droidsaw-strings-")
.suffix(".txt")
.tempfile()
.map_err(|e| {
McpError::internal_error(
format!("failed to create strings tempfile: {e}"),
None,
)
})?;
let strings_file = strings_file_handle.path().to_path_buf();
let result = {
let guard = state.lock().unwrap_or_else(|e| e.into_inner());
let ctx = guard
.as_ref()
.ok_or_else(|| McpError::invalid_params("no file loaded — call load first", None))?;
let hash = CrossLayerContext::hash_path(std::path::Path::new(&ctx.path));
droidsaw_common::diag::with_input_hash(&hash, || {
crate::trufflehog::run::run_and_persist(
ctx,
crate::trufflehog::run::DEFAULT_MIN_LENGTH,
&db_path,
Some(&abort),
Some(&strings_file),
)
})
.unwrap_or_else(|e| serde_json::json!({"ran": false, "error": e.to_string()}))
};
drop(strings_file_handle);
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: Instant::elapsed().as_millis() returns u128 measuring wall-clock since `t_th_start`. The (u128 -> u64) truncation only matters once the elapsed exceeds u64::MAX milliseconds (~584 million years). The value is surfaced as `trufflehog_extract_ms` in the envelope's timings_ms — an operator metric, not a security invariant."
)]
let total_ms = t_th_start.elapsed().as_millis() as u64;
(total_ms, 0u64, result)
} else {
(
0u64,
0u64,
serde_json::json!({
"ran": false,
"skipped_by_mode": mode.as_cli_str(),
}),
)
};
#[allow(
clippy::as_conversions,
reason = "PROOF: usize -> u64 widen of a detector severity-bucket count. usize is ≤ 64 bits on every supported droidsaw target (Linux/macOS x86_64 + aarch64), so the cast is lossless by platform invariant. Counts are bounded by total findings emitted, which is also usize-bounded."
)]
let severity_summary_u64: std::collections::BTreeMap<String, u64> = severity_summary
.into_iter()
.map(|(k, v)| (k, v as u64))
.collect();
let top_findings_len = top_findings.len();
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: u64 -> usize narrow. The sum is over `severity_summary_u64` values, each of which was originally a usize detector count widened to u64 on the same row above. On 64-bit targets (the supported set), usize = u64, so the narrow is identity. On a hypothetical 32-bit target the sum could exceed usize::MAX only if the detector emitted >4G findings — well beyond memory limits."
)]
let top_findings_truncated = top_findings_len
< severity_summary_u64
.iter()
.filter(|(k, _)| matches!(k.as_str(), "Critical" | "High"))
.map(|(_, v)| *v)
.sum::<u64>() as usize;
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: AuditEnvelope construction casts — see comment above. usize -> u64 widens are lossless on supported 64-bit targets; the lone u128 -> u64 (elapsed.as_millis()) is an operator timing metric with a >584M-year truncation horizon."
)]
let envelope = crate::commands::audit_envelope::AuditEnvelope {
schema_version: crate::commands::audit_envelope::AUDIT_ENVELOPE_VERSION,
findings: vec![],
finding_count: findings_persisted as u64,
findings_emitted: total as u64,
taint_flow_count: taint_count,
severity_summary: severity_summary_u64,
severity_by_gauge,
top_findings,
truncated: top_findings_truncated,
db_path: Some(db_path.display().to_string()),
db_queries: Some(db_queries_map),
finding_xrefs_written: Some(finding_xrefs_written as u64),
detectors: None,
trufflehog: Some(trufflehog_result),
semgrep: None,
timings_ms: Some(serde_json::json!({
"collect_findings": collect_findings_ms,
"trufflehog_extract": trufflehog_extract_ms,
"trufflehog_subprocess": trufflehog_subprocess_ms,
"core_total": t_core_start.elapsed().as_millis() as u64,
})),
apk_summary,
meta: crate::commands::audit_envelope::AuditMeta {
count: top_findings_len as u64,
truncated: top_findings_truncated,
hint: format!(
"{findings_persisted} findings, {taint_count} taint flows. \
Audit DB at {db_path}. \
Orient with SELECT * FROM audit_summary, then SELECT * FROM actionable_findings. \
Views are pre-built — see the views list. \
Use investigate, decompile, xrefs, and manifest to investigate. \
Filter on gauge_class='Semantic' to skip noise.",
db_path = db_path.display(),
),
related: vec![
"query".to_string(),
"investigate".to_string(),
"decompile".to_string(),
"xrefs".to_string(),
],
thread_pool_size: rayon::current_num_threads(),
},
};
let obj = serde_json::to_value(&envelope)
.map_err(|e| sanitize_to_mcp_error("audit envelope serialize", &anyhow::anyhow!("{}", e), ErrorCategory::InternalError))?
.as_object()
.cloned()
.ok_or_else(|| McpError::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
"audit envelope did not serialize to JSON object".to_string(),
None,
))?;
Ok(obj)
}
impl Default for DroidsawServer {
fn default() -> Self {
Self::new()
}
}
#[tool_router(router = tool_router)]
impl DroidsawServer {
pub fn new() -> Self {
Self::with_concurrency(
ConcurrencyConfig::new(
1,
1,
2,
2,
8,
),
)
}
pub fn with_concurrency(concurrency: ConcurrencyConfig) -> Self {
Self {
state: Arc::new(Mutex::new(None)),
current_db: Arc::new(Mutex::new(None)),
concurrency: Arc::new(concurrency),
tool_router: Self::tool_router(),
allowed_tool_classes: McpToolClass::default_allowed(),
}
}
pub fn with_allowed_classes(
allowed: std::collections::BTreeSet<McpToolClass>,
) -> Self {
let concurrency = ConcurrencyConfig::new(
1,
1,
2,
2,
8,
);
Self {
state: Arc::new(Mutex::new(None)),
current_db: Arc::new(Mutex::new(None)),
concurrency: Arc::new(concurrency),
tool_router: Self::tool_router(),
allowed_tool_classes: allowed,
}
}
pub fn with_tool_tier(mut self, tier: McpToolTier) -> Self {
if matches!(tier, McpToolTier::Full) {
return self;
}
let all_names: Vec<String> = self
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for name in all_names {
if matches!(tool_tier(&name), McpToolTier::Full) {
self.tool_router.disable_route(name);
}
}
self
}
fn enforce_tool_class(&self, tool_name: &str) -> Result<(), McpError> {
let class = tool_class(tool_name);
if self.allowed_tool_classes.contains(&class) {
return Ok(());
}
let allowed = self
.allowed_tool_classes
.iter()
.map(|c| c.as_kebab())
.collect::<Vec<_>>()
.join(", ");
Err(McpError::invalid_params(
format!(
"tool-class-not-allowed: tool '{tool_name}' (class '{}') refused by operator policy; \
allowed classes: [{allowed}]; expand via --allowed-tool-classes",
class.as_kebab()
),
None,
))
}
fn resolve_db_path(&self, override_: Option<&str>) -> Result<std::path::PathBuf, McpError> {
match override_ {
Some(p) => is_allowed_load_path(p, PathRole::Database),
None => {
let cached = {
let guard = self.current_db.lock().unwrap_or_else(|e| e.into_inner());
guard.clone()
};
let path = cached.ok_or_else(|| {
McpError::invalid_params(
"no db_path provided and no session DB yet — \
run `audit` first, or pass `db_path` explicitly to \
query a DB produced by a previous session",
None,
)
})?;
if !path.is_file() {
let mut guard = self.current_db.lock().unwrap_or_else(|e| e.into_inner());
*guard = None;
let raw = format!(
"session DB at {} is gone (deleted or moved); \
re-run `audit`, or pass `db_path` explicitly",
path.display()
);
return Err(McpError::invalid_params(
crate::mcp::sanitize::redact_paths(&raw),
None,
));
}
Ok(path)
}
}
}
fn with_ctx<F, R>(&self, f: F) -> Result<R, McpError>
where
F: FnOnce(&CrossLayerContext) -> anyhow::Result<R>,
{
let guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
let ctx = guard
.as_ref()
.ok_or_else(|| McpError::invalid_params("no file loaded — call load first", None))?;
let hash = CrossLayerContext::hash_path(std::path::Path::new(&ctx.path));
droidsaw_common::diag::with_input_hash(&hash, || f(ctx))
.map_err(|e| sanitize_to_mcp_error("with_ctx", &e, ErrorCategory::InternalError))
}
#[allow(dead_code, reason = "documented API; available for future tool migrations")]
async fn with_ctx_blocking<F, R>(&self, f: F) -> Result<R, McpError>
where
F: FnOnce(&CrossLayerContext) -> anyhow::Result<R> + Send + 'static,
R: Send + 'static,
{
let state = Arc::clone(&self.state);
tokio::task::spawn_blocking(move || {
let guard = state.lock().unwrap_or_else(|e| e.into_inner());
let ctx = guard
.as_ref()
.ok_or_else(|| McpError::invalid_params("no file loaded — call load first", None))?;
let hash = CrossLayerContext::hash_path(std::path::Path::new(&ctx.path));
droidsaw_common::diag::with_input_hash(&hash, || f(ctx))
.map_err(|e| sanitize_to_mcp_error("with_ctx_blocking", &e, ErrorCategory::InternalError))
})
.await
.map_err(|join_err| {
McpError::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
format!("blocking task panicked: {join_err}"),
Some(serde_json::json!({
"type": "BlockingTaskJoin",
"detail": join_err.to_string(),
})),
)
})?
}
#[tool(description = "Load an APK, HBC, or DEX file into the server. \
Subsequent tools operate on the loaded context. Returns a summary of \
layers discovered (hbc/dex). Must be called first.")]
pub async fn load(
&self,
Parameters(params): Parameters<LoadFileParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("load")?;
let path = is_allowed_load_path(¶ms.path, PathRole::LoadInput)?;
let hash = CrossLayerContext::hash_path(&path);
let mut budget = droidsaw_common::budget::ParseBudget {
memory_bytes_remaining: MCP_LOAD_DEFAULT_BUDGET_BYTES,
steps_remaining: usize::MAX,
deadline: None,
};
let ctx = droidsaw_common::diag::with_input_hash(&hash, || {
CrossLayerContext::parse(&path, Some(&mut budget))
})
.map_err(|e| sanitize_to_mcp_error("parse", &e, ErrorCategory::InternalError))?;
let summary = serde_json::json!({
"path": params.path,
"hbc_present": ctx.hbc.is_some(),
"hbc_parse_error": ctx.hbc_parse_error.as_ref().map(|f| f.message()),
"dex_count": ctx.dex.len(),
});
let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(ctx);
{
let mut db_guard = self.current_db.lock().unwrap_or_else(|e| e.into_inner());
*db_guard = None;
}
let concurrency_info = serde_json::json!({
"mcp_concurrency_refused_total": self.concurrency.refused_total(),
});
let mut summary_obj = summary.as_object().cloned().unwrap_or_default();
summary_obj.insert("_concurrency".into(), concurrency_info);
Ok(serde_json::Value::Object(summary_obj).to_string())
}
#[tool(description = "AndroidManifest analysis. Returns permissions, \
components, exported surface, and findings. Defaults to lenient parsing \
— unknown AXML chunk types (e.g. the 0x0104 commercial-obfuscator marker \
on DexGuard-protected builds) are skipped and reported in `_meta.warnings` \
rather than crashing the parse. Pass `strict: true` to opt back into the \
historical hard-fail behaviour.")]
pub async fn manifest(
&self,
Parameters(params): Parameters<ManifestParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("manifest")?;
let cfg = if params.strict {
droidsaw_apk::ParseConfig::strict()
} else {
droidsaw_apk::ParseConfig::lenient()
};
let value = self.with_ctx(|ctx| crate::commands::manifest_with_config(ctx, &cfg))?;
Ok(value.to_string())
}
#[tool(description = "APK signing info. Returns v1 cert + v2/v3/v4 \
block presence + per-signer verification verdict and public key material.")]
pub async fn signing(
&self,
Parameters(_): Parameters<NoParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("signing")?;
let value = self.with_ctx(crate::commands::signing)?;
Ok(value.to_string())
}
#[tool(description = "Lightweight APK summary: package, version, layers \
present, finding count by severity, up to 5 top critical/high findings, and \
a `signer_summary` array projecting per-block signing cert identity \
(scheme, sha256_fingerprint, plus subject_cn/subject_o/not_before/not_after \
for the v1 block; v2/v3 entries leave subject DN + validity fields null \
because the per-signer struct doesn't surface them — call `signing` to \
drill in). A sibling `signer_summary_status` field carries \"ok\" or \
\"parse_failed\" so an empty array on a corrupted PKCS#7 envelope is \
distinguishable from an empty array on an unsigned APK. For the full \
findings list use `audit` (writes a queryable SQLite DB) + `query`. \
Returns {apk_info: {...}, _meta}.")]
pub async fn info(
&self,
Parameters(_): Parameters<NoParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("info")?;
let value = self.with_ctx(crate::commands::apk_info)?;
Ok(value.to_string())
}
#[tool(description = "Modular security audit across all layers. \
Writes findings to a SQLite DB with source and confidence tracking; \
returns `db_path` for follow-up queries. `output` sets the DB path; \
omit to use a stable per-input temp path (recommended — re-runs upsert \
into the same file). \
`mode` selects which detectors run: `\"basic\"` (default) is parser-side \
findings + bundled YARA only — no subprocess spawns, ~10-30 sec wall on \
most APKs. `\"full\"` adds semgrep + trufflehog (typically 1-15 minutes \
on real-world apps; semgrep subprocess dominates latency). `\"semgrep\"` \
and `\"trufflehog\"` overlay one subprocess each on top of basic. \
Source confidence: taint/manifest = high (verified paths/facts), \
semgrep = medium (pattern on real code), trufflehog = low-medium \
(string pattern, unverified), yara = low (byte pattern, high FP on \
non-code). DB semantics: re-running the same mode upserts by stable \
finding identity (no duplicates); running a different mode adds rows \
under a new `mode` tag; `update_db = false` clears prior rows for this \
mode before inserting. See `timings_ms` for per-phase breakdown. \
Use investigate + decompile to confirm or dismiss findings, \
then triage to persist the decision. \
Concurrency: full/semgrep/trufflehog modes are rate-limited (max 1 \
concurrent, 8/min by default); basic mode is uncapped. \
Related: query (explore DB), triage (confirm/dismiss).")]
pub async fn audit(
&self,
Parameters(params): Parameters<AuditFullParams>,
ct: CancellationToken,
) -> Result<String, McpError> {
self.enforce_tool_class("audit")?;
let t_audit_start = std::time::Instant::now();
let mode = match params.mode.as_deref() {
None => droidsaw_cli_contract::AuditMode::Basic,
Some(s) => droidsaw_cli_contract::AuditMode::from_cli_str(s).ok_or_else(|| {
McpError::invalid_params(
format!("unknown audit mode: {s:?} (expected basic|full|semgrep|trufflehog)"),
None,
)
})?,
};
let tool_class = if mode.runs_semgrep() || mode.runs_trufflehog() {
ToolClass::AuditFull
} else {
ToolClass::ReadOnly
};
let _permit = self.concurrency.acquire(tool_class)?;
let update_db = params.update_db.unwrap_or(true);
let output = params.output.as_deref()
.map(|p| is_allowed_path(p, PathRole::WriteOutput))
.transpose()?;
let entropy = params.entropy;
if ct.is_cancelled() {
return Err(McpError::new(
rmcp::model::ErrorCode(-32000),
"audit cancelled: client disconnected".to_owned(),
Some(serde_json::json!({"type": "Cancelled"})),
));
}
let abort_flag = crate::mcp::subprocess::new_abort_flag();
let watcher = {
let ct = ct.clone();
let abort_flag = Arc::clone(&abort_flag);
tokio::spawn(async move {
ct.cancelled().await;
abort_flag.store(true, std::sync::atomic::Ordering::Relaxed);
})
};
let state_arc = Arc::clone(&self.state);
let concurrency_arc = Arc::clone(&self.concurrency);
let abort_for_blocking = Arc::clone(&abort_flag);
let obj_result = tokio::task::spawn_blocking(move || {
run_core_audit_blocking(
state_arc,
concurrency_arc,
mode,
entropy,
update_db,
abort_for_blocking,
)
})
.await
.map_err(|e| McpError::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
format!("blocking task panicked: {e}"),
Some(serde_json::json!({"type": "BlockingTaskJoin"})),
))?;
watcher.abort();
let mut obj = obj_result?;
if ct.is_cancelled() {
return Err(McpError::new(
rmcp::model::ErrorCode(-32000),
"audit cancelled: client disconnected".to_owned(),
Some(serde_json::json!({"type": "Cancelled"})),
));
}
let db_path_str = obj.get("db_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let db_path = std::path::PathBuf::from(&db_path_str);
let (semgrep_extract_ms, semgrep_subprocess_ms) = if mode.runs_semgrep() {
let t_sg_extract = std::time::Instant::now();
let mcp_semgrep_args = crate::semgrep::SemgrepArgs::default();
let sg_val = self.with_ctx(|ctx| {
crate::commands::semgrep(ctx, output.as_deref(), &mcp_semgrep_args)
}).unwrap_or_else(|e| serde_json::json!({"error": e.to_string()}));
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: Instant::elapsed().as_millis() u128 -> u64 truncation. The value surfaces in timings_ms as `semgrep_extract` — an operator metric with a >584M-year truncation horizon."
)]
let semgrep_extract_ms = t_sg_extract.elapsed().as_millis() as u64;
#[allow(
clippy::indexing_slicing,
reason = "serde_json::Value indexing returns Null on miss, not panic"
)]
let output_dir = sg_val["output_dir"].as_str().unwrap_or("").to_string();
if ct.is_cancelled() {
return Err(McpError::new(
rmcp::model::ErrorCode(-32000),
"audit cancelled: client disconnected before semgrep subprocess".to_owned(),
Some(serde_json::json!({"type": "Cancelled"})),
));
}
let t_sg_subprocess = std::time::Instant::now();
let semgrep_scan = if !output_dir.is_empty() {
crate::semgrep::run_and_persist(
std::path::Path::new(&output_dir),
&mcp_semgrep_args,
&db_path,
Some(&abort_flag),
)
.unwrap_or_else(|e| serde_json::json!({"ran": false, "error": e.to_string()}))
} else {
#[allow(
clippy::indexing_slicing,
reason = "serde_json::Value indexing returns Null on miss, not panic"
)]
let sg_cmd = sg_val["command"]
.as_str()
.unwrap_or("semgrep --config auto <output_dir>/")
.to_string();
serde_json::json!({"ran": false, "command": sg_cmd})
};
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: Instant::elapsed().as_millis() u128 -> u64 truncation. The value surfaces in timings_ms as `semgrep_subprocess` — an operator metric with a >584M-year truncation horizon."
)]
let semgrep_subprocess_ms = t_sg_subprocess.elapsed().as_millis() as u64;
let mut sg_obj = sg_val.as_object().cloned().unwrap_or_default();
sg_obj.insert("semgrep_scan".into(), semgrep_scan);
obj.insert("semgrep".into(), serde_json::Value::Object(sg_obj));
(semgrep_extract_ms, semgrep_subprocess_ms)
} else {
obj.insert("semgrep".into(), serde_json::json!({
"semgrep_scan": {
"ran": false,
"skipped_by_mode": mode.as_cli_str(),
},
}));
(0u64, 0u64)
};
let leads = generate_investigation_leads(&obj, &db_path_str);
obj.insert("leads".into(), leads);
obj.insert("mode".into(), serde_json::json!(mode.as_cli_str()));
obj.insert("update_db".into(), serde_json::json!(update_db));
obj.insert("mcp_concurrency_refused_total".into(),
serde_json::json!(self.concurrency.refused_total()));
if let Some(t) = obj.get_mut("timings_ms").and_then(|v| v.as_object_mut()) {
t.insert("semgrep_extract".into(), serde_json::json!(semgrep_extract_ms));
t.insert("semgrep_subprocess".into(), serde_json::json!(semgrep_subprocess_ms));
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: Instant::elapsed().as_millis() u128 -> u64 truncation. The value surfaces in timings_ms as `audit_total` — an operator metric with a >584M-year truncation horizon."
)]
let audit_total_ms = t_audit_start.elapsed().as_millis() as u64;
t.insert("audit_total".into(), serde_json::json!(audit_total_ms));
}
if !db_path_str.is_empty() {
let mut guard = self.current_db.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(db_path.clone());
}
Ok(serde_json::Value::Object(obj).to_string())
}
#[tool(description = "Query the audit SQLite DB. Views: \
audit_summary, actionable_findings, finding_context, finding_urls, \
taint_critical, semgrep_hotspots. Every table has a _fts variant \
for full-text search: SELECT * FROM findings_fts WHERE findings_fts \
MATCH 'token OR secret'. SELECT plus a curated set of read-only \
PRAGMAs is permitted: PRAGMA table_info(<table>), PRAGMA \
table_xinfo(<table>), PRAGMA index_list(<table>), \
PRAGMA foreign_key_list(<table>). The PRAGMA \
assignment form and side-effecting PRAGMAs are rejected. \
`db_path` is optional — defaults to this session's most recent `audit` DB; \
pass explicitly to query a DB from another session. \
Related: investigate (finding→xref→decompile shortcut), taint.")]
pub async fn query(
&self,
Parameters(params): Parameters<DbQueryParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("query")?;
if let Err(reason) = is_allowed_query_sql(¶ms.sql) {
return Err(McpError::invalid_params(reason, None));
}
let db_path = self.resolve_db_path(params.db_path.as_deref())?;
let db = rusqlite::Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.map_err(|e| sanitize_to_mcp_error("db_query open", &e, ErrorCategory::InternalError))?;
let mut stmt = db
.prepare(¶ms.sql)
.map_err(|e| sanitize_to_mcp_error("db_query prepare", &e, ErrorCategory::InternalError))?;
let col_names: Vec<String> = stmt
.column_names()
.into_iter()
.map(String::from)
.collect();
let col_count = col_names.len();
let limit = params.limit;
let rows: Vec<Vec<serde_json::Value>> = stmt
.query_map([], |row| {
let mut cells = Vec::with_capacity(col_count);
for i in 0..col_count {
let cell = match row.get_ref(i) {
Ok(rusqlite::types::ValueRef::Null) => serde_json::Value::Null,
Ok(rusqlite::types::ValueRef::Integer(n)) => serde_json::json!(n),
Ok(rusqlite::types::ValueRef::Real(f)) => serde_json::json!(f),
Ok(rusqlite::types::ValueRef::Text(s)) => {
serde_json::json!(String::from_utf8_lossy(s))
}
Ok(rusqlite::types::ValueRef::Blob(b)) => {
serde_json::json!(format!("<blob {} bytes>", b.len()))
}
Err(_) => serde_json::Value::Null,
};
cells.push(cell);
}
Ok(cells)
})
.map_err(|e| sanitize_to_mcp_error("db_query execute", &e, ErrorCategory::InternalError))?
.take(limit)
.filter_map(|r| r.ok())
.collect();
let row_count = rows.len();
let out = serde_json::json!({
"columns": col_names,
"rows": rows,
"row_count": row_count,
"truncated": row_count == limit,
"db_path_resolved": db_path.display().to_string(),
});
Ok(out.to_string())
}
#[tool(description = "Finding → xrefs → decompile in one call. \
Use this instead of manually chaining query + xrefs + decompile. \
Pass `rowid` (from query on findings) or `search` (FTS5 term). \
`db_path` is optional — defaults to this session's most recent `audit` DB. \
Returns {finding, xrefs, callers}. \
Related: query (browse findings), decompile (deeper drill-down).")]
pub async fn investigate(
&self,
Parameters(params): Parameters<FindingContextParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("investigate")?;
let db_path = self.resolve_db_path(params.db_path.as_deref())?;
let db = rusqlite::Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.map_err(|e| sanitize_to_mcp_error("finding_context open", &e, ErrorCategory::InternalError))?;
let (finding_detail, finding_id, finding_sev) = if let Some(rowid) = params.rowid {
db.query_row(
"SELECT id_tag, detail, severity FROM findings WHERE rowid = ?1",
rusqlite::params![rowid],
|row| Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?, row.get::<_, String>(2)?)),
)
.map_err(|e| sanitize_to_mcp_error("finding not found", &e, ErrorCategory::NotFound))?
} else if let Some(ref q) = params.search {
db.query_row(
"SELECT findings.id_tag, findings.detail, findings.severity FROM findings \
JOIN findings_fts ON findings.rowid = findings_fts.rowid \
WHERE findings_fts MATCH ?1 ORDER BY rank LIMIT 1",
rusqlite::params![q],
|row| Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?, row.get::<_, String>(2)?)),
)
.map_err(|e| sanitize_to_mcp_error("finding_context fts", &e, ErrorCategory::InternalError))?
} else {
return Err(McpError::invalid_params(
"provide either `rowid` or `search`",
None,
));
};
let search_token: String = finding_detail
.split_whitespace()
.filter(|t| t.len() > 6 && !t.starts_with("APK_") && !t.starts_with("DART_"))
.max_by_key(|t| t.len())
.unwrap_or(&finding_detail)
.to_string();
let xref_val = self
.with_ctx(|ctx| crate::commands::xrefs(ctx, Some(&search_token), Some(20)))?;
#[allow(
clippy::indexing_slicing,
reason = "serde_json::Value indexing returns Null on miss, not panic"
)]
let xrefs = xref_val["xrefs"].as_array().cloned().unwrap_or_default();
let callers: Vec<serde_json::Value> = if params.decompile && !xrefs.is_empty() {
let func_ids: Vec<String> = xrefs
.iter()
.flat_map(|x| {
x["functions"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|f| {
let s = f.as_str()?.to_string();
#[allow(
clippy::string_slice,
reason = "i + 2 lands at a char boundary; (# is ASCII"
)]
let id = s.rfind("(#")
.and_then(|i| s[i.saturating_add(2)..].strip_suffix(')'))
.map(String::from)?;
Some(id)
})
})
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.take(3)
.collect();
func_ids
.iter()
.filter_map(|id| {
self.with_ctx(|ctx| {
crate::commands::decompile(ctx, Some(id), false, false)
})
.ok()
.and_then(|v| {
#[allow(
clippy::indexing_slicing,
reason = "serde_json::Value indexing returns Null on miss, not panic"
)]
let funcs = v["functions"].as_array().and_then(|a| a.first().cloned());
funcs
})
})
.collect()
} else {
vec![]
};
let out = serde_json::json!({
"finding": {
"id_tag": finding_id,
"severity": finding_sev,
"detail": finding_detail,
"search_token": search_token,
},
"xrefs": xrefs,
"callers": callers,
"db_path_resolved": db_path.display().to_string(),
});
Ok(out.to_string())
}
#[tool(description = "Surface taint flows from an audit DB. Shortcut \
for querying the taint_flows table produced by audit. Covers three \
finding IDs: DEX_TAINT_FLOW (interprocedural Java, cross-DEX, depth 4), \
BRIDGE_TAINT_FLOW (JS NativeModule → @ReactMethod Java), HBC_TAINT_FLOW \
(Hermes DirectEval + bridge Call sinks). Returns \
{taint_count, critical, high, source_summary, sink_summary}. Filter by \
source_type (e.g. IntentExtra, NetworkResponse, SharedPreferencesRead, \
ReactBridgeParam, UserInput) or sink_type (e.g. SqlExecute, RuntimeExec, \
WebViewLoad, LogOutput, Eval, NativeModuleArg). ReactBridgeParam sources \
are cross-layer: JS-controlled inputs flowing from @NativeModule calls \
through the React Native bridge into @ReactMethod Java bodies. \
`db_path` is optional — defaults to this session's most recent `audit` \
DB (any mode — basic, semgrep, trufflehog, or full).")]
pub async fn taint(
&self,
Parameters(params): Parameters<TaintFlowsParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("taint")?;
let db_path = self.resolve_db_path(params.db_path.as_deref())?;
let db = rusqlite::Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.map_err(|e| sanitize_to_mcp_error("taint_flows open", &e, ErrorCategory::InternalError))?;
let (where_clause, bind_values) =
build_taint_where_clause(params.source_type.as_deref(), params.sink_type.as_deref());
let count_sql = format!("SELECT COUNT(*) FROM taint_flows {where_clause}");
let count: i64 = db
.query_row(
&count_sql,
rusqlite::params_from_iter(bind_values.iter()),
|r| r.get(0),
)
.unwrap_or(0);
let sql = format!(
"SELECT rowid,layer,func_id,source_type,sink_type,severity,cwe,source_offset,sink_offset \
FROM taint_flows {where_clause} \
ORDER BY CASE severity WHEN 'Critical' THEN 0 WHEN 'High' THEN 1 ELSE 2 END \
LIMIT 50"
);
let mut stmt = db.prepare(&sql)
.map_err(|e| sanitize_to_mcp_error("taint prepare", &e, ErrorCategory::InternalError))?;
let mut critical = vec![];
let mut high = vec![];
let mut source_counts: std::collections::BTreeMap<String, usize> = Default::default();
let mut sink_counts: std::collections::BTreeMap<String, usize> = Default::default();
let rows = stmt
.query_map(rusqlite::params_from_iter(bind_values.iter()), |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, Option<i64>>(7)?,
row.get::<_, Option<i64>>(8)?,
))
})
.map_err(|e| sanitize_to_mcp_error("taint query", &e, ErrorCategory::InternalError))?;
for row in rows.flatten() {
let (rowid, layer, func_id, source, sink, sev, cwe, source_offset, sink_offset) = row;
let src_c = source_counts.entry(source.clone()).or_insert(0);
*src_c = src_c.saturating_add(1);
let snk_c = sink_counts.entry(sink.clone()).or_insert(0);
*snk_c = snk_c.saturating_add(1);
let entry = serde_json::json!({
"rowid": rowid, "layer": layer, "func_id": func_id,
"source_type": source, "sink_type": sink,
"severity": sev, "cwe": cwe,
"source_offset": source_offset, "sink_offset": sink_offset,
});
match sev.as_str() {
"Critical" => critical.push(entry),
"High" => high.push(entry),
_ => {}
}
}
Ok(serde_json::json!({
"taint_count": count,
"critical": critical,
"high": high,
"source_summary": source_counts,
"sink_summary": sink_counts,
"db_path_resolved": db_path.display().to_string(),
}).to_string())
}
#[tool(description = "Triage a finding: confirm or dismiss. Updates \
the audit DB so triage state persists across sessions. Dismissed \
findings are excluded from actionable_findings. Use after investigating \
with decompile/xrefs. `db_path` is optional — defaults to this session's \
most recent `audit` DB. Related: query (check state), investigate.")]
pub async fn triage(
&self,
Parameters(params): Parameters<TriageParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("triage")?;
let db_path = self.resolve_db_path(params.db_path.as_deref())?;
let mut result = crate::commands::triage_finding(
&db_path,
params.rowid,
¶ms.action,
params.reason.as_deref(),
)
.map_err(|e| sanitize_to_mcp_error("triage", &e, ErrorCategory::InternalError))?;
if let Some(obj) = result.as_object_mut() {
obj.insert(
"db_path_resolved".into(),
serde_json::json!(db_path.display().to_string()),
);
}
Ok(result.to_string())
}
#[tool(description = "Search strings across all loaded layers (Hermes \
+ DEX + native .so + resources.arsc) in one call. Do not grep raw files — \
this searches parsed string pools and ELF read-only sections. Returns \
{strings: [...], _meta}. Default limit 200. Use `search` regex and \
`min_length` to narrow. Use `layer` to restrict: \"dex\", \"hbc\", \
\"native\" (.rodata + .dynstr of every .so in the APK; min_length \
defaults to 4), or \"arsc\" (resources.arsc global string pool — URLs, \
config values, JWT-shaped tokens that live only in compiled resources). \
Related: xrefs (trace a string to its callers).")]
pub async fn strings(
&self,
Parameters(params): Parameters<StringsParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("strings")?;
let value = self.with_ctx(|ctx| {
crate::commands::strings(
ctx,
params.search.as_deref(),
params.min_length,
params.limit,
params.layer.as_deref(),
)
})?;
Ok(value.to_string())
}
#[tool(description = "Cross-reference strings, type descriptors, and \
method descriptors to the functions that reference them. Bidirectional: \
find which functions use a string/type/method, or what each function \
references. Do not grep extracted sources — xrefs covers all layers in \
one call. Returns {xrefs: [{layer, kind, string, functions}], _meta}; \
`kind` is one of \"string\" (const-string load), \"type\" (new-instance / \
check-cast / instance-of / new-array / filled-new-array / const-class), \
or \"method\" (any invoke-*). Default limit 50. `search` is a regex \
matched against the key (string value, type descriptor, or callee triple \
`class->name+proto`). Empty pattern and patterns longer than 4 KiB are \
rejected. Related: decompile (read the function), investigate \
(finding→xref→decompile).")]
pub async fn xrefs(
&self,
Parameters(params): Parameters<XrefsParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("xrefs")?;
let value = self.with_ctx(|ctx| {
crate::commands::xrefs(ctx, params.search.as_deref(), params.limit)
})?;
Ok(value.to_string())
}
#[tool(description = "Bulk-decompile envelope across DEX + HBC layers. \
Returns `{functions: [{layer, function_id, name, source}], findings, _meta}` \
where each entry is one DEX class (layer `dex<n>`, function_id=class_idx, \
name=descriptor, source=Java) or one HBC function (layer `hbc`, \
function_id=fid, name=function name, source=JS). \
Use `all: true` for the union envelope across every loaded layer — \
hybrid APKs (React Native: HBC bundle + DEX classes) surface both layers \
in one response. \
`target` = single HBC function ID or DEX class descriptor \
(e.g. `Lcom/example/Foo;`); `js: true` forces HBC JS emit. \
For narrow DEX queries (regex search, outline mode, method filtering, \
dry_run) prefer `decompile` — it returns `{classes: ...}` shape tuned \
for per-class browsing. Use `apk_decompile` when you want every class \
emitted as a single envelope (bench-style head-to-head, audit sweep).")]
pub async fn apk_decompile(
&self,
Parameters(params): Parameters<DecompileParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_decompile")?;
let value = self.with_ctx(|ctx| {
crate::commands::decompile(ctx, params.target.as_deref(), params.js, params.all)
})?;
Ok(value.to_string())
}
#[tool(description = "Generate Frida hooks for functions referencing \
strings that match `search`. Returns {hooks: [...], _meta}. Output is \
ready to paste into a Frida JS file. Pair with `xrefs` to identify \
which function IDs are worth hooking.")]
pub async fn frida(
&self,
Parameters(params): Parameters<FridaParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("frida")?;
let value = self.with_ctx(|ctx| crate::commands::frida(ctx, ¶ms.search))?;
Ok(value.to_string())
}
#[tool(description = "Hermes bytecode bundle metadata: format version, \
string pool size, function count, and section layout with byte offsets. \
HBC layer only — errors if no HBC bundle is loaded. Use `info` for a \
cross-layer summary that includes HBC. Related: hbc_functions (list functions), \
module_list (bundle segment structure).")]
pub async fn hbc_info(
&self,
Parameters(_): Parameters<NoParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("hbc_info")?;
let value = self.with_ctx(crate::commands::hbc_info)?;
Ok(value.to_string())
}
#[tool(description = "List or search Hermes bytecode function names in \
the loaded HBC bundle. HBC layer only — use `dex_methods` for DEX/Java. \
`search` accepts a regex. Returns function IDs, names, and byte offsets. \
Use the returned func_id with `decompile` (readable JS) or `disasm` (raw \
bytecode). Related: decompile, disasm, call_graph, dex_methods (DEX equiv).")]
pub async fn hbc_functions(
&self,
Parameters(params): Parameters<SearchParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("hbc_functions")?;
let value = self.with_ctx(|ctx| {
crate::commands::hbc_functions(ctx, params.search.as_deref())
})?;
Ok(value.to_string())
}
#[tool(description = "List or search DEX class descriptors across all \
loaded DEX files. `search` accepts a regex on the descriptor \
(e.g. `^Lcom/example/`). Returns class names and DEX index. Use the \
index with `decompile` (`class_index` param). DEX layer only — use \
`hbc_functions` for HBC. Related: dex_methods (method browser), decompile.")]
pub async fn dex_classes(
&self,
Parameters(params): Parameters<SearchParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("dex_classes")?;
let value = self.with_ctx(|ctx| {
crate::commands::dex_classes(ctx, params.search.as_deref())
})?;
Ok(value.to_string())
}
#[tool(description = "List or search DEX method signatures across all \
loaded DEX files. `search` accepts a regex on the method descriptor. \
Returns class, method name, parameter types, and return type. DEX layer \
only — use `hbc_functions` for HBC. Related: dex_classes (class browser), \
decompile (decompile the containing class), call_graph.")]
pub async fn dex_methods(
&self,
Parameters(params): Parameters<SearchParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("dex_methods")?;
let value = self.with_ctx(|ctx| {
crate::commands::dex_methods(ctx, params.search.as_deref(), false)
})?;
Ok(value.to_string())
}
#[tool(description = "Export the loaded session's findings to a SQLite \
database at `output` path. The schema matches the DB produced by `audit` — \
use `query`, `investigate`, `taint`, and `triage` on it. Prefer `audit` \
for new sessions (it runs detectors AND writes the DB). Use `apk_export` \
when you want to persist findings from a session already in memory to a \
specific caller-chosen path. Related: audit (preferred), query.")]
pub async fn apk_export(
&self,
Parameters(params): Parameters<ExportParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_export")?;
let output = is_allowed_path(¶ms.output, PathRole::WriteOutput)?;
let output_str = output.to_str().ok_or_else(|| {
McpError::invalid_params("non-UTF-8 output path", None)
})?;
let value = self.with_ctx(|ctx| crate::commands::export(ctx, output_str))?;
Ok(value.to_string())
}
#[tool(description = "List Hermes bundle module segments — the named \
chunks that make up a React Native JS bundle (e.g. \
`node_modules/react/index.js`). HBC layer only. Shows segment names and \
byte ranges. Distinct from `npm_packages`: module_list shows bundle \
segments by file path; npm_packages shows semantic npm metadata (name, \
version, license). Related: npm_packages, functions, hbc_info.")]
pub async fn module_list(
&self,
Parameters(_): Parameters<NoParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("module_list")?;
let value = self.with_ctx(crate::commands::module_list)?;
Ok(value.to_string())
}
#[tool(description = "List native shared libraries (.so files) present \
in the APK. Returns ELF file paths, target architectures, and sizes. Use \
for a quick native-layer inventory before drilling into symbols with \
`apk_elf`. Related: apk_elf (ELF symbol details), strings (search \
native .rodata and .dynstr strings with `layer: \"native\"`).")]
pub async fn native_modules(
&self,
Parameters(_): Parameters<NoParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("native_modules")?;
let value = self.with_ctx(crate::commands::native_modules)?;
Ok(value.to_string())
}
#[tool(description = "Disassemble a single Hermes bytecode function to \
raw HBC instructions. Pass `func_id` from `hbc_functions`. Use when `decompile` \
output doesn't expose enough detail — e.g. tracing the exact origin of a \
DirectEval operand. Prefer `decompile` for readable JS output; use \
`disasm` only for low-level bytecode inspection. HBC layer only. \
Related: hbc_functions (get func_id), decompile (readable alternative).")]
pub async fn disasm(
&self,
Parameters(params): Parameters<DisasmParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("disasm")?;
let value = self.with_ctx(|ctx| crate::commands::disasm(ctx, params.func_id))?;
Ok(value.to_string())
}
#[tool(description = "List npm packages bundled in the loaded Hermes / \
React Native bundle. Returns package name, version, and license for each \
detected package. HBC layer only. Use for supply-chain surface mapping. \
Distinct from `module_list`: npm_packages shows semantic package metadata; \
module_list shows raw bundle segment paths. Related: module_list, strings.")]
pub async fn npm_packages(
&self,
Parameters(_): Parameters<NoParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("npm_packages")?;
let value = self.with_ctx(crate::commands::npm_packages)?;
Ok(value.to_string())
}
#[tool(description = "Function call graph for the loaded file. Returns \
caller→callee edges. Use `search` (regex) to focus on functions matching \
a name pattern; `limit` caps returned edges (default 50). DEX and HBC \
layers supported. Useful for tracing which code reaches a sensitive \
function. Related: decompile (read a function body), xrefs \
(string→function edges), dex_methods, hbc_functions.")]
pub async fn call_graph(
&self,
Parameters(params): Parameters<CallGraphParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("call_graph")?;
let value = self.with_ctx(|ctx| {
crate::commands::call_graph(ctx, params.search.as_deref(), params.limit)
})?;
Ok(value.to_string())
}
#[tool(description = "Decompile to readable source. DEX → Java \
(SSA-optimized, structured control flow), Hermes → JS (OXC-validated). \
Do not use external decompilers — this tool \
produces high-fidelity output across all supported layers. \
Pass `class_index` (0-based) or `search` (regex on class descriptor). \
Search decompiles EVERY match — use a tight regex. For long classes \
(deeplink routers, generated facades) pass `mode: \"outline\"` to bound \
per-class output (class header + method signatures + first ~20 lines \
per body) or `methods: [\"name1\", \"name2\"]` to limit emit to listed \
methods. Both filters compose. \
Pass `dry_run: true` to preview how many classes a regex matches and \
their estimated sizes WITHOUT invoking the decompiler — avoids token \
blowup on broad regex searches (e.g. `^LIL1/.*;$` matching 78 classes). \
Returns `{classes: [{layer, class_index, descriptor, source}], _meta}` — \
narrow per-class shape. For the bulk envelope across every layer \
(DEX + HBC union, one entry per class/function), use `apk_decompile` \
with `all: true`. \
Related: xrefs (find which class to decompile), investigate (finding→decompile in one call), apk_decompile (bulk envelope).")]
pub async fn decompile(
&self,
Parameters(params): Parameters<DexDecompileParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("decompile")?;
if params.dry_run == Some(true) {
let value = self.with_ctx(|ctx| {
crate::commands::dex_decompile_dry_run(
ctx,
params.class_index,
params.search.as_deref(),
)
})?;
return Ok(value.to_string());
}
let _permit = self.concurrency.acquire(ToolClass::Default)?;
let mode = match params.mode.as_deref() {
Some("outline") => crate::commands::DecompileMode::Outline,
Some("full") | None => crate::commands::DecompileMode::Full,
Some(other) => {
return Err(McpError::invalid_params(
format!("unknown decompile mode {other:?}; expected \"full\" or \"outline\""),
None,
));
}
};
let methods = params.methods.as_deref();
let value = self.with_ctx(|ctx| {
crate::commands::dex_decompile_filtered(
ctx,
params.class_index,
params.search.as_deref(),
mode,
methods,
)
})?;
Ok(value.to_string())
}
#[tool(description = "Diff the currently-loaded Hermes bundle \
against another file. Load the baseline first via load, then \
call diff with the new version's path. Returns {old_version, \
new_version, string_counts, function_counts, added_strings, \
removed_strings, _meta}. Hermes/HBC only — both the loaded file and \
the new path must contain an HBC bundle; DEX and full APK diff are \
not yet supported.")]
pub async fn diff(
&self,
Parameters(params): Parameters<DiffParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("diff")?;
let new_path = is_allowed_load_path(¶ms.path, PathRole::LoadInput)?;
let value = self.with_ctx(|ctx| crate::commands::diff(ctx, &new_path))?;
Ok(value.to_string())
}
pub async fn corpus_ingest(
&self,
Parameters(params): Parameters<CorpusIngestParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("corpus_ingest")?;
let _permit = self.concurrency.acquire(ToolClass::Default)?;
let paths = vec![is_allowed_load_path(¶ms.dir, PathRole::LoadDirectory)?];
let output_canon = is_allowed_path(¶ms.output, PathRole::WriteOutput)?;
let output_str = output_canon.to_str().ok_or_else(|| {
McpError::invalid_params("non-UTF-8 output path", None)
})?;
let value = crate::commands::corpus_ingest(
&paths,
output_str,
params.tag.as_deref(),
params.skip_existing,
)
.map_err(|e| sanitize_to_mcp_error("corpus_ingest", &e, ErrorCategory::InternalError))?;
Ok(value.to_string())
}
pub async fn apk_entries(
&self,
Parameters(params): Parameters<EntriesParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_entries")?;
let value = self.with_ctx(|ctx| {
crate::commands::entries(ctx, params.search.as_deref(), params.limit)
})?;
Ok(value.to_string())
}
pub async fn apk_elf(
&self,
Parameters(params): Parameters<ElfParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_elf")?;
let value = self.with_ctx(|ctx| {
crate::commands::elf(ctx, params.search.as_deref())
})?;
Ok(value.to_string())
}
pub async fn apk_webview_assets(
&self,
Parameters(params): Parameters<WebviewAssetsParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_webview_assets")?;
let value = self.with_ctx(|ctx| {
crate::commands::webview_assets(
ctx,
params.search.as_deref(),
params.extract.as_deref(),
)
})?;
Ok(value.to_string())
}
pub async fn apk_resources(
&self,
Parameters(params): Parameters<ResourcesParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_resources")?;
let value = self.with_ctx(|ctx| {
crate::commands::resources(ctx, params.search.as_deref(), params.limit)
})?;
Ok(value.to_string())
}
pub async fn apk_sbom(
&self,
Parameters(_): Parameters<NoParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_sbom")?;
let value = self.with_ctx(crate::commands::sbom)?;
Ok(value.to_string())
}
pub async fn apk_yara(
&self,
Parameters(params): Parameters<YaraParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_yara")?;
if let Some(ref src) = params.rules_src
&& let Err(e) = droidsaw_apk::yara_scan::check_directive_policy(src)
{
return Err(sanitize_to_mcp_error("yara rules policy", &e, ErrorCategory::BadRequest));
}
let canon_rules: Option<std::path::PathBuf> = params
.rules
.as_deref()
.map(|p| is_allowed_path(p, PathRole::LoadInputOrDirectory))
.transpose()?;
let rules_path = canon_rules.as_deref();
let value = self.with_ctx(|ctx| {
crate::commands::yara(
ctx,
params.rules_src.as_deref(),
rules_path,
¶ms.target,
params.limit,
)
})?;
Ok(value.to_string())
}
pub async fn apk_semgrep_extract(
&self,
Parameters(params): Parameters<SemgrepParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_semgrep_extract")?;
let explicit_output = params.output.as_deref()
.map(|p| is_allowed_path(p, PathRole::WriteOutput))
.transpose()?;
let tempdir_fallback: Option<std::path::PathBuf> = if explicit_output.is_none() {
let hash = {
let guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
guard.as_ref().map(|c| CrossLayerContext::hash_path(std::path::Path::new(&c.path)))
};
let key = hash.unwrap_or_else(|| "unknown".to_string());
Some(std::env::temp_dir().join(format!("droidsaw-semgrep-{key}")))
} else {
None
};
let effective_output: Option<std::path::PathBuf> = explicit_output.or(tempdir_fallback);
let semgrep_args = crate::semgrep::SemgrepArgs::default();
let value = self.with_ctx(|ctx| {
crate::commands::semgrep(ctx, effective_output.as_deref(), &semgrep_args)
})?;
Ok(value.to_string())
}
pub async fn apk_trufflehog(
&self,
Parameters(params): Parameters<TrufflehogParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_trufflehog")?;
let validated_out: Option<std::path::PathBuf> = params.output.as_deref()
.map(|p| is_allowed_path(p, PathRole::WriteOutput))
.transpose()?;
let value = self.with_ctx(|ctx| {
let mut buf: Vec<u8> = Vec::new();
crate::commands::trufflehog(ctx, params.min_length, &mut buf)?;
let text = String::from_utf8_lossy(&buf);
let filter = match params.search.as_deref() {
Some(pat) => Some(regex::Regex::new(pat)?),
None => None,
};
let lines: Vec<&str> = text
.lines()
.filter(|l| filter.as_ref().is_none_or(|r| r.is_match(l)))
.collect();
let line_count = lines.len();
let out_path = match validated_out.clone() {
Some(p) => p,
None => {
let tf = tempfile::Builder::new()
.prefix("droidsaw-strings-")
.suffix(".txt")
.tempfile()?;
let (_, p) = tf.keep()?;
p
}
};
std::fs::write(&out_path, lines.join("\n"))?;
Ok(serde_json::json!({
"output_file": out_path.display().to_string(),
"lines": line_count,
"command": format!("trufflehog filesystem {} --no-verification", out_path.display()),
"_meta": {
"hint": "pipe output_file to trufflehog filesystem for full credential scanning",
"related": ["apk_yara", "audit"],
},
}))
})?;
Ok(value.to_string())
}
pub async fn apk_scan_corpus(
&self,
Parameters(params): Parameters<ScanCorpusParams>,
) -> Result<String, McpError> {
self.enforce_tool_class("apk_scan_corpus")?;
let paths: Vec<std::path::PathBuf> = params
.paths
.iter()
.map(|p| {
if std::path::Path::new(p).is_dir() {
is_allowed_path(p, PathRole::LoadDirectory)
} else {
is_allowed_path(p, PathRole::LoadInput)
}
})
.collect::<Result<Vec<_>, _>>()?;
let mut buf: Vec<u8> = Vec::new();
crate::commands::scan_corpus(&paths, ¶ms.min_severity, &mut buf)
.map_err(|e| sanitize_to_mcp_error("scan_corpus", &e, ErrorCategory::InternalError))?;
let records: Vec<serde_json::Value> = String::from_utf8_lossy(&buf)
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
let out = serde_json::json!({
"records": records,
"_meta": {
"count": records.len(),
"truncated": false,
"hint": "findings are already filtered by `min_severity`; use apk_audit for a single-APK deep dive",
"related": ["apk_audit", "corpus_ingest", "export"],
},
});
Ok(out.to_string())
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for DroidsawServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_prompts()
.enable_resources()
.build(),
)
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListPromptsResult, McpError> {
Ok(ListPromptsResult {
prompts: prompts::build_prompts(),
..Default::default()
})
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<GetPromptResult, McpError> {
let args = request.arguments.unwrap_or_default();
prompts::render_prompt(&request.name, &args)
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let resource_list = resources::resource_entries()
.iter()
.map(|(uri, name, desc, mime, contents)| {
RawResource {
uri: (*uri).into(),
name: (*name).into(),
title: None,
description: Some((*desc).into()),
mime_type: Some((*mime).into()),
#[allow(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "PROOF: contents.len() -> u32 narrow for the rmcp RawResource.size field. `contents` is a `&'static str` from `include_str!` macros in `mcp/resources.rs` — `skill.md` (~98 bytes) and `guide.md` (~191 bytes). Build-time bounded; both well under u32::MAX. Adding a resource >4 GiB would be a compile-time bug, not a runtime narrowing."
)]
size: Some(contents.len() as u32),
icons: None,
meta: None,
}
.no_annotation()
})
.collect();
Ok(ListResourcesResult {
resources: resource_list,
..Default::default()
})
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ReadResourceResult, McpError> {
for (uri, _, _, mime, contents) in resources::resource_entries() {
if *uri == request.uri {
return Ok(ReadResourceResult::new(vec![
ResourceContents::text(*contents, *uri).with_mime_type(*mime),
]));
}
}
Err(McpError::invalid_params(
format!(
"unknown resource URI: {} — call resources/list to see available URIs",
request.uri
),
None,
))
}
}
#[cfg(test)]
mod tool_class_tests {
use super::*;
use std::str::FromStr;
#[test]
fn parses_kebab_case() {
assert_eq!(
McpToolClass::from_str("read-only").unwrap(),
McpToolClass::ReadOnly
);
assert_eq!(
McpToolClass::from_str("writes-tempfile").unwrap(),
McpToolClass::WritesTempfile
);
assert_eq!(
McpToolClass::from_str("writes-caller-path").unwrap(),
McpToolClass::WritesCallerPath
);
assert_eq!(
McpToolClass::from_str("spawns-subprocess").unwrap(),
McpToolClass::SpawnsSubprocess
);
assert_eq!(
McpToolClass::from_str("manages-state").unwrap(),
McpToolClass::ManagesState
);
}
#[test]
fn rejects_unknown_kebab() {
let err = McpToolClass::from_str("read_only").unwrap_err();
assert!(err.contains("unknown tool class"));
assert!(err.contains("read-only"));
}
#[test]
fn trims_whitespace() {
assert_eq!(
McpToolClass::from_str(" read-only ").unwrap(),
McpToolClass::ReadOnly
);
}
#[test]
fn default_is_read_only_plus_tempfile() {
let d = McpToolClass::default_allowed();
assert!(d.contains(&McpToolClass::ReadOnly));
assert!(d.contains(&McpToolClass::WritesTempfile));
assert!(!d.contains(&McpToolClass::WritesCallerPath));
assert!(!d.contains(&McpToolClass::SpawnsSubprocess));
assert!(!d.contains(&McpToolClass::ManagesState));
}
#[test]
fn known_tool_classifications() {
for name in [
"manifest", "signing", "info", "query", "investigate", "taint",
"strings", "xrefs", "frida", "decompile", "diff",
] {
assert_eq!(
tool_class(name),
McpToolClass::ReadOnly,
"{name} should be ReadOnly"
);
}
assert_eq!(tool_class("load"), McpToolClass::WritesTempfile);
assert_eq!(tool_class("audit"), McpToolClass::SpawnsSubprocess);
assert_eq!(tool_class("triage"), McpToolClass::ManagesState);
}
#[test]
fn newly_classified_readonly_tools() {
for name in [
"apk_decompile",
"hbc_info",
"hbc_functions",
"dex_classes",
"dex_methods",
"module_list",
"native_modules",
"disasm",
"npm_packages",
"call_graph",
"apk_entries",
"apk_elf",
"apk_webview_assets",
"apk_resources",
"apk_sbom",
"apk_scan_corpus",
] {
assert_eq!(
tool_class(name),
McpToolClass::ReadOnly,
"{name} should be ReadOnly"
);
}
}
#[test]
fn newly_classified_writes_caller_path_tools() {
for name in ["apk_export", "corpus_ingest", "apk_semgrep_extract", "apk_trufflehog"] {
assert_eq!(
tool_class(name),
McpToolClass::WritesCallerPath,
"{name} should be WritesCallerPath"
);
}
}
#[test]
fn newly_classified_spawns_subprocess_tools() {
assert_eq!(tool_class("apk_yara"), McpToolClass::SpawnsSubprocess);
assert_eq!(tool_class("audit"), McpToolClass::SpawnsSubprocess);
}
#[test]
fn newly_classified_manages_state_tools() {
assert_eq!(tool_class("triage"), McpToolClass::ManagesState);
}
#[test]
fn unknown_tool_fails_closed() {
assert_eq!(
tool_class("hypothetical_new_tool"),
McpToolClass::ManagesState
);
assert_eq!(tool_class(""), McpToolClass::ManagesState);
}
#[test]
fn enforce_default_policy_permits_read_only_tools() {
let server = DroidsawServer::new();
assert!(server.enforce_tool_class("info").is_ok());
assert!(server.enforce_tool_class("load").is_ok());
assert!(server.enforce_tool_class("query").is_ok());
}
#[test]
fn enforce_default_policy_refuses_destructive_tools() {
let server = DroidsawServer::new();
assert!(server.enforce_tool_class("audit").is_err());
assert!(server.enforce_tool_class("triage").is_err());
assert!(server.enforce_tool_class("apk_yara").is_err());
assert!(server.enforce_tool_class("apk_export").is_err());
assert!(server.enforce_tool_class("corpus_ingest").is_err());
assert!(server.enforce_tool_class("apk_trufflehog").is_err());
}
#[test]
fn enforce_error_message_names_tool_and_class() {
let server = DroidsawServer::new();
let err = server.enforce_tool_class("triage").unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("triage"));
assert!(msg.contains("manages-state"));
assert!(msg.contains("tool-class-not-allowed"));
}
#[test]
fn enforce_with_expanded_policy_permits_destructive() {
let mut allowed = McpToolClass::default_allowed();
allowed.insert(McpToolClass::ManagesState);
allowed.insert(McpToolClass::WritesCallerPath);
let server = DroidsawServer::with_allowed_classes(allowed);
assert!(server.enforce_tool_class("triage").is_ok());
assert!(server.enforce_tool_class("apk_export").is_ok());
assert!(server.enforce_tool_class("audit").is_err());
assert!(server.enforce_tool_class("apk_yara").is_err());
}
#[test]
fn accept_reject_matrix_per_class() {
let read_only_server = DroidsawServer::with_allowed_classes(
[McpToolClass::ReadOnly].into_iter().collect()
);
assert!(read_only_server.enforce_tool_class("info").is_ok());
assert!(read_only_server.enforce_tool_class("hbc_functions").is_ok());
assert!(read_only_server.enforce_tool_class("dex_classes").is_ok());
assert!(read_only_server.enforce_tool_class("apk_scan_corpus").is_ok());
assert!(read_only_server.enforce_tool_class("load").is_err()); assert!(read_only_server.enforce_tool_class("audit").is_err()); assert!(read_only_server.enforce_tool_class("apk_yara").is_err()); assert!(read_only_server.enforce_tool_class("apk_export").is_err()); assert!(read_only_server.enforce_tool_class("triage").is_err());
let spawn_server = DroidsawServer::with_allowed_classes(
[McpToolClass::ReadOnly, McpToolClass::WritesTempfile, McpToolClass::SpawnsSubprocess]
.into_iter().collect()
);
assert!(spawn_server.enforce_tool_class("audit").is_ok());
assert!(spawn_server.enforce_tool_class("apk_yara").is_ok());
assert!(spawn_server.enforce_tool_class("info").is_ok());
assert!(spawn_server.enforce_tool_class("apk_export").is_err()); assert!(spawn_server.enforce_tool_class("triage").is_err());
let write_server = DroidsawServer::with_allowed_classes(
[McpToolClass::ReadOnly, McpToolClass::WritesTempfile, McpToolClass::WritesCallerPath]
.into_iter().collect()
);
assert!(write_server.enforce_tool_class("apk_export").is_ok());
assert!(write_server.enforce_tool_class("corpus_ingest").is_ok());
assert!(write_server.enforce_tool_class("apk_trufflehog").is_ok());
assert!(write_server.enforce_tool_class("apk_semgrep_extract").is_ok());
assert!(write_server.enforce_tool_class("audit").is_err()); assert!(write_server.enforce_tool_class("triage").is_err());
let state_server = DroidsawServer::with_allowed_classes(
[McpToolClass::ReadOnly, McpToolClass::WritesTempfile, McpToolClass::ManagesState]
.into_iter().collect()
);
assert!(state_server.enforce_tool_class("triage").is_ok());
assert!(state_server.enforce_tool_class("audit").is_err()); }
#[test]
fn enforce_with_all_classes_permits_everything() {
let allowed: std::collections::BTreeSet<McpToolClass> =
McpToolClass::all().into_iter().collect();
let server = DroidsawServer::with_allowed_classes(allowed);
for name in ["info", "load", "audit", "triage", "frida", "decompile"] {
assert!(
server.enforce_tool_class(name).is_ok(),
"{name} should be allowed under `all` policy"
);
}
}
#[test]
fn apk_scan_corpus_path_gate_accepts_directories_and_files() {
let tmpdir = std::env::temp_dir();
let tmpdir_str = tmpdir.to_str().unwrap();
assert!(
is_allowed_path(tmpdir_str, PathRole::LoadDirectory).is_ok(),
"temp dir should pass LoadDirectory gate"
);
assert!(
is_allowed_path(tmpdir_str, PathRole::LoadInput).is_err(),
"directory path must fail LoadInput (is_file check)"
);
let tmp_file = tmpdir.join("droidsaw-path-gate-test.tmp");
std::fs::write(&tmp_file, b"test").unwrap();
let tmp_file_str = tmp_file.to_str().unwrap();
assert!(
is_allowed_path(tmp_file_str, PathRole::LoadInput).is_ok(),
"file path should pass LoadInput gate"
);
assert!(
is_allowed_path(tmp_file_str, PathRole::LoadDirectory).is_err(),
"file path must fail LoadDirectory (is_dir check)"
);
let _ = std::fs::remove_file(&tmp_file);
assert!(
is_allowed_path("", PathRole::LoadInput).is_err(),
"empty path must be rejected"
);
assert!(
is_allowed_path("/nonexistent/droidsaw/test/path", PathRole::LoadInput).is_err(),
"non-existent path must fail canonicalization"
);
}
}
#[cfg(test)]
mod current_db_session_tests {
use super::*;
fn fresh_server() -> DroidsawServer {
DroidsawServer::with_concurrency(ConcurrencyConfig::new(1, 1, 2, 2, 8))
}
#[test]
fn resolve_without_override_and_without_session_errors() {
let server = fresh_server();
let err = server.resolve_db_path(None).expect_err("should error");
let msg = format!("{err:?}");
assert!(
msg.contains("no db_path provided"),
"error must explain why: got {msg}",
);
}
#[test]
fn resolve_uses_session_slot_when_override_absent_and_path_lives() {
let server = fresh_server();
let tmpdir = std::env::temp_dir();
let real_path = tmpdir.join("droidsaw-session-resolve-test.db");
std::fs::write(&real_path, b"sqlite-placeholder").unwrap();
{
let mut g = server
.current_db
.lock()
.unwrap_or_else(|e| e.into_inner());
*g = Some(real_path.clone());
}
let resolved = server.resolve_db_path(None).expect("should resolve");
assert_eq!(resolved, real_path);
let _ = std::fs::remove_file(&real_path);
}
#[test]
fn resolve_stale_slot_clears_and_errors() {
let server = fresh_server();
let synthetic = std::path::PathBuf::from("/tmp/droidsaw-stale-never-created.db");
{
let mut g = server
.current_db
.lock()
.unwrap_or_else(|e| e.into_inner());
*g = Some(synthetic);
}
let err = server.resolve_db_path(None).expect_err("stale slot must error");
let msg = format!("{err:?}");
assert!(
msg.contains("is gone"),
"error must mention staleness: got {msg}",
);
let guard = server.current_db.lock().unwrap_or_else(|e| e.into_inner());
assert!(guard.is_none(), "stale slot must be cleared on miss");
}
#[test]
fn resolve_override_present_path_must_pass_allowlist() {
let server = fresh_server();
let synthetic = std::path::PathBuf::from("/tmp/droidsaw-session-test.db");
{
let mut g = server
.current_db
.lock()
.unwrap_or_else(|e| e.into_inner());
*g = Some(synthetic);
}
let res = server.resolve_db_path(Some("/nonexistent/path/audit.db"));
assert!(res.is_err(), "override must flow through path allowlist");
}
}
#[cfg(test)]
mod tool_tier_tests {
use super::*;
use std::str::FromStr;
#[test]
fn parses_kebab_case() {
assert_eq!(McpToolTier::from_str("basic").unwrap(), McpToolTier::Basic);
assert_eq!(McpToolTier::from_str("full").unwrap(), McpToolTier::Full);
assert_eq!(McpToolTier::from_str(" basic ").unwrap(), McpToolTier::Basic);
}
#[test]
fn rejects_unknown_tier() {
let err = McpToolTier::from_str("medium").unwrap_err();
assert!(err.contains("unknown tool tier"));
assert!(err.contains("basic"));
assert!(err.contains("full"));
}
#[test]
fn basic_tier_set_has_exactly_twelve_tools() {
assert_eq!(BASIC_TIER_TOOLS.len(), 12);
}
#[test]
fn tool_tier_classifies_basic_set_as_basic() {
for name in BASIC_TIER_TOOLS {
assert_eq!(
tool_tier(name),
McpToolTier::Basic,
"expected {name} in BASIC_TIER_TOOLS to classify as Basic",
);
}
}
#[test]
fn tool_tier_full_for_non_basic_examples() {
for name in ["hbc_info", "dex_classes", "disasm", "apk_yara", "frida", "diff"] {
assert_eq!(
tool_tier(name),
McpToolTier::Full,
"{name} should classify as Full",
);
}
}
#[test]
fn default_server_exposes_full_surface() {
let server = DroidsawServer::new();
let visible: Vec<String> = server
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
assert!(
visible.iter().any(|n| n == "hbc_info"),
"default server must expose hbc_info; got {visible:?}",
);
assert!(visible.iter().any(|n| n == "audit"));
assert!(visible.iter().any(|n| n == "load"));
}
#[test]
fn basic_tier_hides_full_tools_from_list_all() {
let server = DroidsawServer::new().with_tool_tier(McpToolTier::Basic);
let visible: Vec<String> = server
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for name in BASIC_TIER_TOOLS {
assert!(
visible.iter().any(|n| n == name),
"{name} must remain visible under Basic; got {visible:?}",
);
}
for hidden in ["hbc_info", "dex_classes", "disasm", "apk_yara", "frida"] {
assert!(
!visible.iter().any(|n| n == hidden),
"{hidden} must be hidden under Basic; got {visible:?}",
);
}
}
#[test]
fn full_tier_is_no_op_passthrough() {
let baseline = DroidsawServer::new();
let baseline_visible: std::collections::BTreeSet<String> = baseline
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
let tiered = DroidsawServer::new().with_tool_tier(McpToolTier::Full);
let tiered_visible: std::collections::BTreeSet<String> = tiered
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
assert_eq!(
baseline_visible, tiered_visible,
"with_tool_tier(Full) must be a no-op against the default surface",
);
}
#[test]
fn every_registered_tool_classifies_under_basic_or_full() {
let server = DroidsawServer::new();
for tool in server.tool_router.list_all() {
let t = tool_tier(tool.name.as_ref());
assert!(matches!(t, McpToolTier::Basic | McpToolTier::Full));
}
}
}