droidsaw 2.0.0

DROIDSAW — unified Android reverse engineering CLI. Hermes, DEX, APK signing. JSON output, MCP server. Bytecode is not a security layer.
Documentation
//! `droidsaw-mcp` binary — MCP server, stdio transport only.
//!
//! Second binary in the droidsaw-core crate, compiled only when
//! `--features mcp` is enabled.
//!
//! An MCP client spawns this binary and exchanges JSON-RPC
//! over stdin/stdout. Tracing is forced to stderr because stdout is the
//! transport. Lifetime = parent holds the pipe; close stdin → server exits.
//!
//! Stdio is the only supported transport. The Streamable HTTP transport
//! was removed; see `build.rs` for the build-time gate that prevents
//! re-introduction without an explicit, justified consumer.

#![cfg_attr(not(test), deny(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::unreachable,
    clippy::todo,
    clippy::arithmetic_side_effects,
))]

use clap::Parser;
use droidsaw::mcp::{DroidsawServer, McpToolClass, McpToolTier};
use rmcp::ServiceExt;

#[derive(Parser, Debug)]
#[command(
    name = "droidsaw-mcp",
    about = "DROIDSAW MCP server (stdio transport)."
)]
struct Cli {
    /// Operator policy for tool authorization (mcp-tool-auth-audit minimum subset).
    ///
    /// Comma-separated list of tool classes the server is permitted to dispatch.
    /// Tools outside the list are refused at dispatch time with a typed
    /// `tool-class-not-allowed` error.
    ///
    /// Classes: `read-only`, `writes-tempfile`, `writes-caller-path`,
    /// `spawns-subprocess`, `manages-state`. Symbolic shortcut: `all`.
    /// Default: `read-only,writes-tempfile` (pure-read analysis + load's tempdir
    /// extraction). Destructive classes must be opted in explicitly; operators on
    /// shared or networked hosts keep the default; full-trust local-only setups
    /// can pass `--allowed-tool-classes=all`.
    ///
    /// Also settable via `MCP_ALLOWED_CLASSES`.
    #[arg(
        long,
        env = "MCP_ALLOWED_CLASSES",
        default_value = "read-only,writes-tempfile",
        value_parser = parse_allowed_classes,
    )]
    allowed_tool_classes: std::collections::BTreeSet<McpToolClass>,

    /// Explicit read-path allow-prefix. Caller-supplied paths under
    /// any allow-prefix are exempt from the read-side denylist
    /// (`/etc/`, `/proc/`, `/sys/`, `/dev/`, `/run/secrets/`).
    ///
    /// Repeatable: pass `--allowed-read-path /a --allowed-read-path /b`.
    /// `DROIDSAW_MCP_ROOT` still applies — allow-prefixes can punch
    /// denylist holes but cannot escape the sandbox root.
    ///
    /// Also settable via `DROIDSAW_MCP_ALLOWED_READ_PATH` (colon-separated).
    #[arg(
        long = "allowed-read-path",
        env = "DROIDSAW_MCP_ALLOWED_READ_PATH",
        value_delimiter = ':',
        action = clap::ArgAction::Append,
    )]
    allowed_read_paths: Vec<String>,

    /// Explicit write-path allow-prefix. Caller-supplied write paths
    /// under any allow-prefix are exempt from the write-side denylist
    /// (`/etc/`, `/var/lib/`, `/usr/bin/`, `/usr/sbin/`, `/bin/`,
    /// `/sbin/`, `/proc/`, `/sys/`, `/dev/`, `/run/secrets/`).
    ///
    /// Repeatable: pass `--allowed-write-path /a --allowed-write-path /b`.
    /// `DROIDSAW_MCP_ROOT` still applies — allow-prefixes can punch
    /// denylist holes but cannot escape the sandbox root.
    ///
    /// Also settable via `DROIDSAW_MCP_ALLOWED_WRITE_PATH` (colon-separated).
    #[arg(
        long = "allowed-write-path",
        env = "DROIDSAW_MCP_ALLOWED_WRITE_PATH",
        value_delimiter = ':',
        action = clap::ArgAction::Append,
    )]
    allowed_write_paths: Vec<String>,

    /// Visible tool surface. `basic` exposes a curated 12-tool
    /// core-workflow set (load/info/manifest/signing/audit/query/
    /// investigate/taint/triage/decompile/strings/xrefs); `full`
    /// (default) exposes every registered tool. Tier is orthogonal
    /// to `--allowed-tool-classes`: tier governs visibility,
    /// classes govern dispatch authorization.
    ///
    /// Use `basic` to reduce cognitive load on an LLM agent that
    /// only needs the routine triage workflow; use `full` (the
    /// default) for power-user / developer sessions.
    ///
    /// Also settable via `DROIDSAW_MCP_TOOL_TIER`.
    #[arg(
        long,
        env = "DROIDSAW_MCP_TOOL_TIER",
        default_value = "full",
        value_parser = parse_tool_tier,
    )]
    tool_tier: McpToolTier,
}

fn parse_tool_tier(s: &str) -> Result<McpToolTier, String> {
    use std::str::FromStr;
    McpToolTier::from_str(s)
}

fn parse_allowed_classes(s: &str) -> Result<std::collections::BTreeSet<McpToolClass>, String> {
    use std::str::FromStr;
    if s.trim() == "all" {
        return Ok(McpToolClass::all().into_iter().collect());
    }
    let mut set = std::collections::BTreeSet::new();
    for part in s.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        set.insert(McpToolClass::from_str(part)?);
    }
    if set.is_empty() {
        return Err("at least one tool class required (or pass 'all')".to_string());
    }
    Ok(set)
}

/// Tokio worker-thread stack size for **debug builds only**.
///
/// Empirical bisection (large social-app APK, 54 MiB, 11 DEX,
/// no HBC, basic-mode audit):
///   - debug at  2 MiB: stack overflow
///   - debug at  4 MiB: stack overflow
///   - debug at 16 MiB: clean, 701s wall
///   - release at  2 MiB: clean, 77s wall ← default
///   - release at  8 MiB: clean, 77s wall
///
/// Root cause is debug-build frame bloat (no inlining, no register
/// reuse, conservative slot reservation), NOT a real recursion bug
/// in the audit pipeline. Release builds run fine on tokio's
/// default 2 MiB worker stack; only the `cargo build` (no `--release`)
/// path needs the inflation. The interprocedural DEX taint pass
/// (`analysis::dex_taint::interproc_inner`) recurses 4 levels deep
/// and the function compiles to a small frame in release but a
/// large one in debug — that's the gap this constant closes.
const TOKIO_THREAD_STACK_SIZE_DEBUG: usize = 16 * 1024 * 1024;

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    droidsaw_common::diag::init();

    let mut rt_builder = tokio::runtime::Builder::new_multi_thread();
    rt_builder.enable_all();
    if cfg!(debug_assertions) {
        rt_builder.thread_stack_size(TOKIO_THREAD_STACK_SIZE_DEBUG);
    }
    let runtime = rt_builder.build()?;
    runtime.block_on(async_main(cli))
}

async fn async_main(cli: Cli) -> anyhow::Result<()> {

    // Propagate CLI-supplied allow-prefixes to the env so the
    // `is_allowed_path` helper in `mcp/mod.rs` (which is called from
    // every per-tool handler and cannot easily take config-state via
    // function parameters) sees the operator's intent regardless of
    // whether they configured via CLI or env. Clap's `env = ...`
    // attribute reads at parse-time; this writes the resolved value
    // back so the helper's env lookup is authoritative.
    //
    // SAFETY: `set_var` is unsafe in Rust 1.95+ because env mutation
    // races with other threads. Called pre-runtime-spawn (the
    // `#[tokio::main]` runtime is constructed AFTER `main` returns
    // into it; no async tasks exist yet), so no concurrent reader
    // can observe a torn write.
    if !cli.allowed_read_paths.is_empty() {
        let joined = cli.allowed_read_paths.join(":");
        unsafe { std::env::set_var("DROIDSAW_MCP_ALLOWED_READ_PATH", joined) };
    }
    if !cli.allowed_write_paths.is_empty() {
        let joined = cli.allowed_write_paths.join(":");
        unsafe { std::env::set_var("DROIDSAW_MCP_ALLOWED_WRITE_PATH", joined) };
    }

    run_stdio(cli.allowed_tool_classes, cli.tool_tier).await
}

async fn run_stdio(
    allowed_classes: std::collections::BTreeSet<McpToolClass>,
    tool_tier: McpToolTier,
) -> anyhow::Result<()> {
    // Tracing must go to stderr — stdout is the MCP transport.
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_max_level(tracing::Level::WARN)
        .init();

    tracing::info!(
        "droidsaw-mcp server starting (transport=stdio); allowed-classes={:?}; tool-tier={:?}",
        allowed_classes,
        tool_tier,
    );
    let service = DroidsawServer::with_allowed_classes(allowed_classes)
        .with_tool_tier(tool_tier)
        .serve(rmcp::transport::stdio())
        .await?;
    service.waiting().await?;
    Ok(())
}