vtcode 0.99.1

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

use anyhow::{Result, anyhow};
use url::Url;
use vtcode_tui::app::{InlineListItem, InlineListSelection, ListOverlayRequest, TransientRequest};

pub(crate) const URL_GUARD_TITLE: &str = "Open External Link";
pub(crate) const URL_GUARD_APPROVE_ACTION: &str = "url_guard:approve";
pub(crate) const URL_GUARD_DENY_ACTION: &str = "url_guard:deny";

const TRUSTED_HOSTS: &[&str] = &[
    "auth.openai.com",
    "platform.openai.com",
    "api.openai.com",
    "deepwiki.com",
];

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum UrlGuardDecision {
    Approve,
    Deny,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct UrlGuardPrompt {
    url: String,
    host_label: String,
    insecure_transport: bool,
    local_or_private: bool,
    trusted_host: bool,
}

impl UrlGuardPrompt {
    pub(crate) fn parse(url: String) -> Option<Self> {
        let parsed = Url::parse(&url).ok()?;
        let scheme = parsed.scheme();
        if scheme != "http" && scheme != "https" {
            return None;
        }

        let host = parsed.host_str()?.to_ascii_lowercase();
        let port = parsed
            .port()
            .map(|value| format!(":{value}"))
            .unwrap_or_default();
        let host_label = format!("{host}{port}");

        Some(Self {
            url,
            host_label,
            insecure_transport: scheme == "http",
            local_or_private: is_local_or_private_host(&host),
            trusted_host: is_trusted_host(&host),
        })
    }

    pub(crate) fn url(&self) -> &str {
        &self.url
    }

    pub(crate) fn lines(&self) -> Vec<String> {
        let mut lines = vec![
            "This link may be unsafe. VT Code requires approval before opening external URLs."
                .to_string(),
        ];

        if self.insecure_transport {
            lines.push(
                "Plain HTTP is insecure. Traffic can be intercepted or modified before it reaches your browser."
                    .to_string(),
            );
        } else if self.local_or_private {
            lines.push(
                "This destination is a local or private address. Opening it may interact with services on this machine or network."
                    .to_string(),
            );
        } else if self.trusted_host {
            lines.push(
                "Destination matches VT Code's small built-in trusted host list, but approval is still required."
                    .to_string(),
            );
        } else {
            lines.push(
                "Destination is not in VT Code's small built-in trusted host list. Review it carefully before proceeding."
                    .to_string(),
            );
        }

        if self.insecure_transport && self.local_or_private {
            lines.push(
                "This URL is both plain HTTP and local/private. Only continue if you expected this exact destination."
                    .to_string(),
            );
        }

        lines.push(format!("Host: {}", self.host_label));
        lines.push(format!("URL: {}", self.url));
        lines.push("Choose Open to continue or Cancel to stay in VT Code.".to_string());

        lines
    }

    pub(crate) fn cli_lines(&self) -> Vec<String> {
        self.lines()
            .into_iter()
            .filter(|line| !line.starts_with("URL: "))
            .filter(|line| !line.starts_with("Choose Open"))
            .collect()
    }

    pub(crate) fn items(&self) -> Vec<InlineListItem> {
        let badge = if self.insecure_transport {
            Some("HTTP".to_string())
        } else if self.local_or_private {
            Some("Local".to_string())
        } else if self.trusted_host {
            Some("Known".to_string())
        } else {
            Some("HTTPS".to_string())
        };

        vec![
            InlineListItem {
                title: "Cancel".to_string(),
                subtitle: Some("Do not open this link.".to_string()),
                badge: Some("Default".to_string()),
                indent: 0,
                selection: Some(InlineListSelection::ConfigAction(
                    URL_GUARD_DENY_ACTION.to_string(),
                )),
                search_value: None,
            },
            InlineListItem {
                title: "Open in browser".to_string(),
                subtitle: Some(
                    "Launch the exact URL in your default browser after approval.".to_string(),
                ),
                badge,
                indent: 0,
                selection: Some(InlineListSelection::ConfigAction(
                    URL_GUARD_APPROVE_ACTION.to_string(),
                )),
                search_value: None,
            },
        ]
    }

    pub(crate) fn default_selection(&self) -> InlineListSelection {
        InlineListSelection::ConfigAction(URL_GUARD_DENY_ACTION.to_string())
    }

    pub(crate) fn request(&self) -> TransientRequest {
        TransientRequest::List(ListOverlayRequest {
            title: URL_GUARD_TITLE.to_string(),
            lines: self.lines(),
            footer_hint: None,
            items: self.items(),
            selected: Some(self.default_selection()),
            search: None,
            hotkeys: Vec::new(),
        })
    }
}

pub(crate) fn url_guard_decision(selection: &InlineListSelection) -> Option<UrlGuardDecision> {
    match selection {
        InlineListSelection::ConfigAction(action) if action == URL_GUARD_APPROVE_ACTION => {
            Some(UrlGuardDecision::Approve)
        }
        InlineListSelection::ConfigAction(action) if action == URL_GUARD_DENY_ACTION => {
            Some(UrlGuardDecision::Deny)
        }
        _ => None,
    }
}

pub(crate) fn open_external_url(url: &str) -> Result<()> {
    webbrowser::open(url).map_err(|err| anyhow!("failed to open browser: {err}"))?;
    Ok(())
}

fn is_trusted_host(host: &str) -> bool {
    TRUSTED_HOSTS
        .iter()
        .any(|trusted| host_matches_domain(host, trusted))
}

fn host_matches_domain(host: &str, domain: &str) -> bool {
    host == domain
        || host
            .strip_suffix(domain)
            .is_some_and(|prefix| prefix.ends_with('.'))
}

fn is_local_or_private_host(host: &str) -> bool {
    if matches!(host, "localhost" | "0.0.0.0")
        || host.ends_with(".local")
        || host.ends_with(".internal")
    {
        return true;
    }

    if let Ok(ip) = host.parse::<IpAddr>() {
        return match ip {
            IpAddr::V4(addr) => is_local_or_private_ipv4(addr),
            IpAddr::V6(addr) => is_local_or_private_ipv6(addr),
        };
    }

    false
}

fn is_local_or_private_ipv4(addr: Ipv4Addr) -> bool {
    addr.is_private()
        || addr.is_loopback()
        || addr.is_link_local()
        || addr.is_broadcast()
        || addr.is_documentation()
        || addr.is_unspecified()
}

fn is_local_or_private_ipv6(addr: Ipv6Addr) -> bool {
    addr.is_loopback()
        || addr.is_unspecified()
        || addr.is_unique_local()
        || addr.is_unicast_link_local()
}

#[cfg(test)]
mod tests {
    use super::{
        URL_GUARD_APPROVE_ACTION, URL_GUARD_DENY_ACTION, URL_GUARD_TITLE, UrlGuardDecision,
        UrlGuardPrompt, url_guard_decision,
    };
    use vtcode_tui::app::{InlineListSelection, TransientRequest};

    #[test]
    fn parse_http_url_marks_insecure_transport() {
        let prompt =
            UrlGuardPrompt::parse("http://example.com/docs".to_string()).expect("http prompt");

        let lines = prompt.lines();
        assert!(
            lines
                .iter()
                .any(|line| line.contains("Plain HTTP is insecure"))
        );
    }

    #[test]
    fn parse_https_known_host_marks_trusted() {
        let prompt = UrlGuardPrompt::parse("https://auth.openai.com/oauth/authorize".to_string())
            .expect("trusted host");

        let lines = prompt.lines();
        assert!(
            lines
                .iter()
                .any(|line| line.contains("built-in trusted host list"))
        );
    }

    #[test]
    fn parse_localhost_url_marks_local_private() {
        let prompt = UrlGuardPrompt::parse("https://localhost:1455/auth/callback".to_string())
            .expect("localhost prompt");

        let lines = prompt.lines();
        assert!(
            lines
                .iter()
                .any(|line| line.contains("local or private address"))
        );
    }

    #[test]
    fn cli_lines_omit_url_and_inline_modal_instruction() {
        let prompt =
            UrlGuardPrompt::parse("https://example.com/docs".to_string()).expect("https prompt");

        let lines = prompt.cli_lines();
        assert!(!lines.iter().any(|line| line.starts_with("URL: ")));
        assert!(
            !lines
                .iter()
                .any(|line| line.starts_with("Choose Open to continue"))
        );
    }

    #[test]
    fn request_defaults_to_deny_selection() {
        let prompt =
            UrlGuardPrompt::parse("https://example.com/docs".to_string()).expect("https prompt");

        match prompt.request() {
            TransientRequest::List(request) => {
                assert_eq!(request.title, URL_GUARD_TITLE);
                assert_eq!(request.selected, Some(prompt.default_selection()));
            }
            other => panic!("expected list request, got {other:?}"),
        }
    }

    #[test]
    fn decision_mapping_accepts_approve_and_deny_actions() {
        assert_eq!(
            url_guard_decision(&InlineListSelection::ConfigAction(
                URL_GUARD_APPROVE_ACTION.to_string(),
            )),
            Some(UrlGuardDecision::Approve)
        );
        assert_eq!(
            url_guard_decision(&InlineListSelection::ConfigAction(
                URL_GUARD_DENY_ACTION.to_string(),
            )),
            Some(UrlGuardDecision::Deny)
        );
    }
}