procyon 0.3.0

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! The gate on operations that spend real funds.
//!
//! Until now `mainnet` was a parameter like any other: the system prompt asked the agent to state
//! the network and stop for confirmation, and that is guidance, not a guarantee. One wrong string
//! from the model was a real transfer. This makes it a refusal instead.
//!
//! The gate is a setting the human turns on out of band, in `config.toml` or the environment,
//! which is the property that matters: the model cannot grant it to itself mid-conversation, and
//! cannot argue its way past it. It is not an approval prompt — Procyon has no way to ask, since
//! a tool runs with no channel to the UI — so it does not claim to be one. Once enabled it stays
//! enabled for the session, and every mainnet call it permits is still logged.
//!
//! Scope: only operations that sign and submit. Reads, simulations and diagnostics cost nothing
//! and are not gated — gating them would push the agent toward `invoke` for questions `read`
//! answers for free, which is the opposite of what safety wants.

/// Whether a network name refers to the public Stellar network.
///
/// Matched by name because that is all a tool is given. A Caatinga network is whatever
/// `caatinga.config.ts` calls it, so an operator who names the public network `prod` is outside
/// what this can detect — which is why the message below names the setting rather than promising
/// the check is exhaustive.
pub fn is_public_network(network: &str) -> bool {
    matches!(
        network.trim().to_lowercase().as_str(),
        "mainnet" | "public" | "pubnet" | "main"
    )
}

/// Whether the human has opted into mainnet operations.
///
/// The environment wins over the config file so a CI job can grant it for one run without
/// committing a config that grants it forever.
///
/// Public because the interface has to answer the same question the gate does. It used to be
/// private, and the status line simply displayed whatever network had been selected — so `/network
/// mainnet` left the screen saying mainnet while every signing operation was refused, with nothing
/// reconciling the two. On a question about real funds there cannot be two sources of truth.
pub fn mainnet_allowed() -> bool {
    if let Ok(value) = std::env::var("PROCYON_ALLOW_MAINNET") {
        return matches!(value.trim().to_lowercase().as_str(), "1" | "true" | "yes");
    }

    crate::config::AppConfig::load()
        .map(|config| config.allow_mainnet)
        .unwrap_or(false)
}

/// Resolves the network for an operation that signs and submits.
///
/// The network must be stated. A signing operation that leaves it implicit inherits a default
/// this process cannot see — `caatinga.config.ts` holds Caatinga's — so "unspecified" cannot be
/// distinguished from "mainnet", and a gate with that hole in it is decoration. Requiring it also
/// puts the target in the session log, where an audit can find it.
pub fn resolve_signing_network(network: Option<&str>) -> Result<String, String> {
    let Some(network) = network.map(str::trim).filter(|n| !n.is_empty()) else {
        return Err(
            "'network' must be stated for an operation that signs and submits. Name the network \
             explicitly (e.g. testnet) rather than relying on a default this tool cannot see."
                .to_string(),
        );
    };

    if is_public_network(network) && !mainnet_allowed() {
        return Err(format!(
            "Refusing to sign on '{}': mainnet operations are disabled. This spends real funds, \
             so it is off unless the operator turns it on out of band — set `allow_mainnet = true` \
             in ~/.config/procyon/config.toml, or PROCYON_ALLOW_MAINNET=1 in the environment. \
             Ask the user to do it; you cannot enable it yourself. Nothing was submitted.",
            network
        ));
    }

    Ok(network.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_public_network_is_recognised_by_its_common_spellings() {
        for name in [
            "mainnet",
            "MAINNET",
            " Mainnet ",
            "public",
            "pubnet",
            "main",
        ] {
            assert!(is_public_network(name), "missed {:?}", name);
        }
    }

    #[test]
    fn other_networks_are_not_the_public_one() {
        for name in ["testnet", "local", "futurenet", "standalone", ""] {
            assert!(!is_public_network(name), "false positive on {:?}", name);
        }
    }

    // The gate's whole value is that the model cannot reach it. A missing network would otherwise
    // inherit a default this process cannot inspect, so it is refused rather than assumed safe.
    #[test]
    fn a_signing_operation_must_name_its_network() {
        for absent in [None, Some(""), Some("   ")] {
            let err = resolve_signing_network(absent)
                .expect_err("an unstated network must not be assumed");
            assert!(err.contains("must be stated"), "{}", err);
        }
    }

    #[test]
    fn a_non_public_network_passes_through() {
        assert_eq!(resolve_signing_network(Some("testnet")).unwrap(), "testnet");
        assert_eq!(resolve_signing_network(Some(" local ")).unwrap(), "local");
    }

    // Read as one test rather than two: the env var is process-wide, so a parallel test that
    // toggled it would race this one.
    #[test]
    fn mainnet_is_refused_unless_the_operator_enabled_it() {
        // SAFETY of the assumption, not of the call: no other test reads this variable.
        let restore = std::env::var("PROCYON_ALLOW_MAINNET").ok();

        std::env::remove_var("PROCYON_ALLOW_MAINNET");
        // With the variable unset the config decides, and a machine running these tests may have
        // it enabled; only the refusal wording is asserted when it is in fact refused.
        if let Err(err) = resolve_signing_network(Some("mainnet")) {
            assert!(err.contains("real funds"), "{}", err);
            assert!(err.contains("allow_mainnet"), "{}", err);
            assert!(
                err.contains("cannot enable it yourself"),
                "the message must close the door on the model asking itself: {}",
                err
            );
            assert!(err.contains("Nothing was submitted"), "{}", err);
        }

        std::env::set_var("PROCYON_ALLOW_MAINNET", "1");
        assert_eq!(resolve_signing_network(Some("mainnet")).unwrap(), "mainnet");

        std::env::set_var("PROCYON_ALLOW_MAINNET", "0");
        assert!(
            resolve_signing_network(Some("mainnet")).is_err(),
            "an explicit 0 must not enable mainnet"
        );

        match restore {
            Some(value) => std::env::set_var("PROCYON_ALLOW_MAINNET", value),
            None => std::env::remove_var("PROCYON_ALLOW_MAINNET"),
        }
    }
}