cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! `cordance serve` — entry point for the stdio MCP server.
//!
//! This file is intentionally tiny: the protocol, transport, tool surface,
//! and safety guardrails all live in [`crate::mcp`]. The previous hand-rolled
//! JSON-RPC dispatcher (bug at line 47: tracing pollutes stdout; bug at line
//! 133: no canonicalisation of `target`) has been replaced with the official
//! `rmcp` 1.7 SDK and a path-validation guard.
//!
//! Invariants this wrapper enforces:
//! 1. MCP stdout is reserved for JSON-RPC frames. The tracing subscriber
//!    installed here writes exclusively to stderr (`with_writer(stderr)`).
//! 2. **The MCP allow-list is the launch CWD only.** No filesystem position
//!    any target or server-CWD `cordance.toml` claims has any effect. The
//!    server allow-list is constructed from the `default_target` parameter
//!    alone, with the additional-roots slice empty. (Round-4 redteam HIGH
//!    R4-redteam-4: previously `[mcp].allowed_roots` was honoured from the
//!    server-CWD's `cordance.toml`. An operator who `cd`'d into an
//!    untrusted directory before running `cordance serve` could be tricked
//!    into widening the allow-list to `["/"]`. The new policy refuses to
//!    honour `[mcp].allowed_roots` from any `cordance.toml`.) Doctrine,
//!    axiom, and LLM knobs continue to come from the target's config —
//!    those are per-target product settings, not security policy.
//! 3. Every tool that takes a `target` parameter routes through
//!    [`crate::mcp::validation::validate_target`].
//! 4. The server is single-process and synchronous from the CLI's POV; the
//!    tokio runtime is built on entry and torn down on exit.

use anyhow::Result;
use camino::Utf8PathBuf;

use crate::config::Config;
use crate::mcp::server::{serve_stdio, CordanceServer};
use crate::mcp::validation::AllowedRoots;

/// Drive the MCP stdio loop until the peer closes stdin (EOF) or the OS
/// delivers a signal.
///
/// `default_target` is the directory the operator launched `cordance serve`
/// from; it is the **sole** entry of the allow-list and the value used when
/// a tool call omits the `target` argument.
///
/// `cfg` is the *target-derived* configuration (doctrine, axiom, LLM). The
/// MCP allow-list does NOT use `cfg.mcp.allowed_roots` — Round-4 redteam HIGH
/// R4-redteam-4 closes the launch-CWD `cordance.toml` widening attack by
/// pinning the allow-list to `default_target` regardless of any
/// project-config claim. A non-empty `cfg.mcp.allowed_roots` triggers a
/// `tracing::warn!` so the operator notices their config is being ignored.
pub fn run(cfg: &Config, default_target: &Utf8PathBuf) -> Result<()> {
    // MCP stdout is reserved for JSON-RPC frames. Logging must never touch
    // stdout. This subscriber writes exclusively to stderr.
    let _ = tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_env("CORDANCE_LOG")
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
        )
        .try_init();

    tracing::info!(
        target = %default_target,
        "cordance serve: rmcp 1.7 stdio server starting (stderr-only logging)"
    );

    // Round-4 redteam HIGH R4-redteam-4: the MCP allow-list is the launch
    // CWD only. We do NOT read `[mcp].allowed_roots` from any
    // `cordance.toml` — neither the target's nor the server-CWD's — because
    // a hostile cordance.toml with `allowed_roots = ["/"]` would otherwise
    // widen the server allow-list to the entire filesystem. The previous
    // server-CWD-sourced policy was load-bearing for round-2's threat model
    // but still allowed the "operator `cd`'d into an untrusted repo" trap.
    //
    // If the target's `cordance.toml` declares `[mcp].allowed_roots`, the
    // value is ignored. We log a single `tracing::warn!` so the operator
    // sees their config is being silently dropped.
    if !cfg.mcp.allowed_roots.is_empty() {
        tracing::warn!(
            extra = ?cfg.mcp.allowed_roots,
            "ignoring [mcp].allowed_roots from project cordance.toml; allow-list is launch-CWD only"
        );
    }

    let allowed = AllowedRoots::from_config(default_target.as_std_path(), &[]);
    tracing::debug!(
        target = %default_target,
        roots = ?allowed.roots(),
        "mcp allow-list resolved (launch CWD only)"
    );

    // The runtime config carries the target's doctrine/axiom/LLM knobs
    // unchanged. The `[mcp]` section is no longer used by `cordance serve`;
    // we leave the target's value in place (some downstream tools read it
    // for diagnostic purposes, and rewriting it to defaults would mask the
    // ignored-config warning above on subsequent reloads).
    let runtime_cfg = cfg.clone();

    let server = CordanceServer::new(runtime_cfg, allowed);

    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()?;
    runtime.block_on(async move {
        if let Err(e) = serve_stdio(server).await {
            tracing::error!(error = %e, "cordance serve: stdio loop exited with error");
            return Err::<(), anyhow::Error>(anyhow::anyhow!("serve_stdio: {e}"));
        }
        Ok(())
    })?;

    tracing::info!("cordance serve: clean shutdown");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::McpConfig;
    use std::path::PathBuf;

    /// Round-4 redteam HIGH R4-redteam-4: even when a `cordance.toml` lying
    /// at the server-CWD declares `[mcp].allowed_roots = ["/"]`, the resolved
    /// MCP allow-list must contain ONLY the default target. The fix pins the
    /// allow-list to the launch-CWD argument and passes an empty extras
    /// slice — `Config::load_strict(&server_cwd)` is no longer called, so
    /// the hostile widening is structurally impossible.
    ///
    /// This test mirrors what `serve_cmd::run` does at the
    /// `AllowedRoots::from_config(default_target.as_std_path(), &[])` call
    /// site: an `extras` slice of `&[]` regardless of any config value the
    /// caller managed to plant.
    #[test]
    fn allowed_roots_are_launch_cwd_only_even_with_hostile_config() {
        let dir = tempfile::tempdir().expect("tempdir");
        let server_cwd = dir.path().to_path_buf();

        // Simulate a hostile server-CWD `cordance.toml` that tries to widen
        // the allow-list. Under the old policy this would have been honoured.
        // Under R4-redteam-4 the value is parsed by `Config::load_strict`
        // (still valid TOML) but ignored by `serve_cmd::run`.
        std::fs::write(
            server_cwd.join("cordance.toml"),
            "[mcp]\nallowed_roots = [\"/\"]\n",
        )
        .expect("write hostile cordance.toml");

        // What `serve_cmd::run` actually does: build the allow-list from
        // `default_target` alone with empty extras. Pass the parsed-but-
        // ignored config view to demonstrate the wire shape.
        let hostile_cfg_view = McpConfig {
            allowed_roots: vec!["/".to_string()],
        };
        // The behaviour under test: the extras slice is `&[]` regardless of
        // whatever `hostile_cfg_view.allowed_roots` claims.
        let _ = hostile_cfg_view; // exercises the field; keeps borrow checker happy.
        let allowed = AllowedRoots::from_config(server_cwd.as_path(), &[]);

        // The resolved root must canonicalise into something under the
        // tempdir's prefix and MUST NOT include the filesystem root.
        let canon_cwd = dunce::canonicalize(server_cwd.as_path()).expect("canonicalise server cwd");
        let roots: Vec<PathBuf> = allowed.roots().to_vec();
        assert_eq!(
            roots.len(),
            1,
            "allow-list must contain exactly one entry (launch CWD)"
        );
        assert_eq!(
            roots[0], canon_cwd,
            "the only entry must be the canonicalised launch CWD"
        );
        // Defence-in-depth: the filesystem root must NOT appear in the
        // resolved allow-list. (On Windows the canonical form of `/` is
        // typically the volume root, which the assertion above already
        // excludes; this is the cross-platform restatement of the same
        // promise.)
        for root in &roots {
            let s = root.to_string_lossy();
            assert!(
                s.contains(canon_cwd.to_string_lossy().as_ref()),
                "resolved root {s:?} must be inside the launch CWD"
            );
        }
    }

    /// Round-4 redteam HIGH R4-redteam-4: parameterise the same shape so a
    /// non-empty extras slice from the **caller** would still be honoured —
    /// the policy only blocks reading from `cordance.toml`. This pins that
    /// the `AllowedRoots::from_config` API itself is unchanged; what
    /// `serve_cmd::run` does differently is pass `&[]`.
    #[test]
    fn empty_extras_yields_cwd_only_allow_list() {
        let dir = tempfile::tempdir().expect("tempdir");
        let cwd = dir.path().to_path_buf();
        let allowed = AllowedRoots::from_config(cwd.as_path(), &[]);
        assert_eq!(allowed.roots().len(), 1);
        let canon = dunce::canonicalize(cwd.as_path()).expect("canon");
        assert_eq!(allowed.roots()[0], canon);
    }
}