Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 107 additions & 36 deletions crates/lpm-cli/src/commands/install/firewall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,7 @@ pub(super) fn finish_npm_firewall_preflight(
if matches!(stats.mode, NpmFirewallMode::Monitor) {
if !json_output && (blocked_count > 0 || warned_count > 0) {
output::warn(&format!(
"npm firewall monitor found {} would-block and {} warned package(s); command continues because monitor mode is active.",
"LPM Firewall monitor: {} would-block, {} warned; command continues because monitor mode is active.",
blocked_count, warned_count
));
print_firewall_decisions(&blocked);
Expand All @@ -939,8 +939,9 @@ pub(super) fn finish_npm_firewall_preflight(

if warned_count > 0 && !json_output {
output::warn(&format!(
"npm firewall warned for {} package(s):",
warned_count
"LPM Firewall warned for {} {}:",
warned_count,
install_ui::packages_word(warned_count),
));
print_firewall_decisions(&warned);
}
Expand All @@ -951,14 +952,16 @@ pub(super) fn finish_npm_firewall_preflight(

if !json_output {
output::warn(&format!(
"npm firewall blocked {} package(s):",
blocked_count
"LPM Firewall blocked {} {}:",
blocked_count,
install_ui::packages_word(blocked_count),
));
print_firewall_decisions(&blocked);
}
Err(LpmError::Registry(format!(
"{} package(s) blocked by LPM npm firewall",
blocked_count
"{} {} blocked by LPM Firewall",
blocked_count,
install_ui::packages_word(blocked_count),
)))
}

Expand Down Expand Up @@ -1226,44 +1229,62 @@ fn npm_firewall_decision_json(decision: &NpmFirewallDecision) -> serde_json::Val
fn print_firewall_decisions(decisions: &[&NpmFirewallDecision]) {
for decision in decisions {
for line in firewall_decision_lines(decision) {
eprintln!("{line}");
install_ui::detail_line(line);
}
}
}

fn firewall_decision_lines(decision: &NpmFirewallDecision) -> Vec<String> {
let name = lpm_common::sanitize_for_terminal(&decision.name);
let version = lpm_common::sanitize_for_terminal(&decision.version);
if let Some(display) = &decision.display
&& let Some(summary) = non_empty_display_text(display.summary.as_deref())
{
let action = decision.action.as_str();
let summary = lpm_common::sanitize_for_terminal(summary);
let mut lines = vec![format!(" {name}@{version} - {action}: {summary}")];
if let Some(report_url) = non_empty_display_text(display.report_url.as_deref()) {
let report_url = lpm_common::sanitize_for_terminal(report_url);
lines.push(format!(" report: {report_url}"));
}
return lines;
}

let verdict = lpm_common::sanitize_for_terminal(&decision.verdict);
let reason = lpm_common::sanitize_for_terminal(&decision.reason);
let context = firewall_decision_context(decision);
let mut lines = vec![format!(
" {name}@{version} - {verdict}: {reason}{context}"
)];
fn firewall_decision_lines(decision: &NpmFirewallDecision) -> Vec<install_ui::TerminalLine> {
let package = format!("{}@{}", decision.name, decision.version);
let row = install_ui::terminal_line!(
" {} {} - ",
firewall_action_field(decision.action, "›"),
firewall_action_field(decision.action, &package),
);
let summary = decision
.display
.as_ref()
.and_then(|display| non_empty_display_text(display.summary.as_deref()));
let row = if let Some(summary) = summary {
install_ui::terminal_line!(
"{}{}: {}",
row,
firewall_action_field(decision.action, decision.action.as_str()),
summary,
)
} else {
install_ui::terminal_line!(
"{}{}: {}{}",
row,
firewall_action_field(decision.action, &decision.verdict),
decision.reason,
firewall_decision_context(decision),
)
};
let mut lines = Vec::with_capacity(2);
lines.push(row);
if let Some(report_url) = decision
.display
.as_ref()
.and_then(|display| non_empty_display_text(display.report_url.as_deref()))
{
let report_url = lpm_common::sanitize_for_terminal(report_url);
lines.push(format!(" report: {report_url}"));
lines.push(install_ui::terminal_line!(
" {} {}",
install_ui::dim("report:"),
install_ui::hyperlink(report_url),
));
}
lines
}

fn firewall_action_field(action: NpmFirewallAction, text: &str) -> install_ui::TerminalFragment {
match action {
NpmFirewallAction::Block => install_ui::red(text),
NpmFirewallAction::Warn => install_ui::yellow(text),
NpmFirewallAction::Allow => install_ui::green(text),
}
}

fn non_empty_display_text(value: Option<&str>) -> Option<&str> {
value.and_then(|text| {
let trimmed = text.trim();
Expand Down Expand Up @@ -1373,6 +1394,13 @@ mod tests {
);
}

fn plain_firewall_decision_lines(decision: &NpmFirewallDecision) -> Vec<String> {
firewall_decision_lines(decision)
.iter()
.map(|line| console::strip_ansi_codes(line.as_ref()).into_owned())
.collect()
}

#[test]
fn firewall_decision_lines_prefer_display_summary_and_report_url() {
let decision = firewall_decision_with_display(
Expand All @@ -1381,9 +1409,9 @@ mod tests {
);

assert_eq!(
firewall_decision_lines(&decision),
plain_firewall_decision_lines(&decision),
vec![
" moi-computer@0.1.0 - warn: May alter local Git configuration and add or overwrite package-owned agent skills in selected workspaces.".to_string(),
" moi-computer@0.1.0 - warn: May alter local Git configuration and add or overwrite package-owned agent skills in selected workspaces.".to_string(),
" report: https://firewall.lpm.dev/npm/moi-computer/v/0.1.0".to_string(),
]
);
Expand All @@ -1397,14 +1425,57 @@ mod tests {
);

assert_eq!(
firewall_decision_lines(&decision),
plain_firewall_decision_lines(&decision),
vec![
" moi-computer@0.1.0 - warn: summarybell?line?return?back?end".to_string(),
" moi-computer@0.1.0 - warn: summarybell?line?return?back?end".to_string(),
" report: https://firewall.lpm.dev/bell?line?return?back?end".to_string(),
]
);
}

#[test]
fn firewall_decision_lines_retain_fallback_verdict_reason_and_context() {
let mut decision = firewall_decision_with_display(
" ",
"https://firewall.lpm.dev/npm/moi-computer/v/0.1.0",
);
decision.policy = Some(NpmFirewallDecisionPolicy {
group: "lpm_ai_suspicious".to_string(),
key: None,
intent: None,
default_action: None,
});
decision.authority = Some(NpmFirewallDecisionAuthority {
source: "lpm_ai".to_string(),
source_type: None,
external_intel: None,
});

assert_eq!(
plain_firewall_decision_lines(&decision),
vec![
" › moi-computer@0.1.0 - suspicious: client policy maps lpm_ai_suspicious to warn (policy lpm_ai_suspicious, source lpm_ai)",
" report: https://firewall.lpm.dev/npm/moi-computer/v/0.1.0",
]
);
}

#[test]
fn firewall_decision_lines_sanitize_fallback_package_and_verdict_fields() {
let mut decision = firewall_decision_with_display("", "");
decision.name = "package\nforged".to_string();
decision.version = "1.0.0\x1b[2J".to_string();
decision.verdict = "malicious\x1b]52;c;AAAA\x07".to_string();
decision.reason = "reason\rforged".to_string();
decision.policy = None;
decision.authority = None;

assert_eq!(
plain_firewall_decision_lines(&decision),
vec![" › package?forged@1.0.0 - malicious: reason?forged"],
);
}

#[test]
fn npm_firewall_decision_json_includes_display_metadata() {
let decision = NpmFirewallDecision {
Expand Down
1 change: 1 addition & 0 deletions crates/lpm-cli/src/commands/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1717,6 +1717,7 @@ async fn run_with_options_under_store_lock(
client,
&all_packages,
&link_result.materialized,
security_analysis_policy,
false,
verbose,
fetch_lpm_security_insights,
Expand Down
104 changes: 104 additions & 0 deletions crates/lpm-cli/src/install_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,42 @@ pub fn url(text: &str) -> TerminalFragment {
TerminalFragment(lpm_common::sanitize_terminal_inline(text).blue())
}

/// Renders a visible HTTP(S) URL with a hyperlink in interactive color output.
/// Redirected output and invalid targets retain only the sanitized URL text.
pub fn hyperlink(text: &str) -> TerminalFragment {
format_hyperlink(
text,
lpm_common::color::enabled() && std::io::stderr().is_terminal(),
)
}

fn format_hyperlink(text: &str, enabled: bool) -> TerminalFragment {
let safe = lpm_common::sanitize_terminal_inline(text);
let fallback = || TerminalFragment(safe.blue());
if !enabled || safe != text {
return fallback();
}
let Ok(target) = reqwest::Url::parse(text) else {
return fallback();
};
if !matches!(target.scheme(), "http" | "https")
|| target.host_str().is_none()
|| !target.username().is_empty()
|| target.password().is_some()
{
return fallback();
}

let visible = target.as_str().blue();
let mut rendered = String::with_capacity(target.as_str().len() + visible.len() + 14);
rendered.push_str("\x1b]8;;");
rendered.push_str(target.as_str());
rendered.push_str("\x1b\\");
rendered.push_str(&visible);
rendered.push_str("\x1b]8;;\x1b\\");
TerminalFragment(rendered)
}

/// Green helper for success/status-value roles.
pub fn green(text: &str) -> TerminalFragment {
TerminalFragment(lpm_common::sanitize_terminal_inline(text).green())
Expand Down Expand Up @@ -1043,6 +1079,74 @@ mod tests {
assert!(result.is_err());
}

#[test]
fn hyperlink_wraps_the_visible_url_and_survives_line_composition() {
let url = "https://firewall.lpm.dev/npm/example/v/1.0.0";
let link = super::format_hyperlink(url, true);
let row = terminal_line!(" report: {}", link);
assert_eq!(
row.to_string(),
format!(
" report: \x1b]8;;{url}\x1b\\{}\x1b]8;;\x1b\\",
super::url(url)
)
);
}

#[test]
fn hyperlink_without_terminal_support_keeps_only_the_visible_url() {
let url = "https://firewall.lpm.dev/npm/example/v/1.0.0";
assert_eq!(super::format_hyperlink(url, false), super::url(url));
}

#[test]
fn hyperlink_uses_the_encoded_url_as_its_target() {
for (input, target) in [
("https://example.test/a b", "https://example.test/a%20b"),
(
"https://example.test/\u{202e}forged",
"https://example.test/%E2%80%AEforged",
),
] {
let link = super::format_hyperlink(input, true);
assert_eq!(
link.to_string(),
format!("\x1b]8;;{target}\x1b\\{}\x1b]8;;\x1b\\", super::url(target)),
);
}
}

#[test]
fn hyperlink_rejects_non_web_targets_and_embedded_credentials() {
for target in [
"javascript:alert(1)",
"file:///tmp/report",
"mailto:user@example.test",
"not a URL",
"https://",
"https://user:secret@example.test/report",
"https://user@example.test/report",
] {
assert_eq!(super::format_hyperlink(target, true), super::url(target));
}
}

#[test]
fn hyperlink_never_embeds_terminal_controls_from_the_target() {
for target in [
"https://example.test/\x1b]52;c;AAAA\x07",
"https://example.test/\x1b\\\x1b[2J",
"https://example.test/\x07",
"https://example.test/\nforged",
"https://example.test/\rforged",
"https://example.test/\u{009c}",
] {
let rendered = super::format_hyperlink(target, true);
assert_eq!(rendered, super::url(target));
assert!(!rendered.contains("\x1b]"));
}
}

#[test]
fn package_source_identity_keeps_only_registry_origin() {
let identity = safe_package_source_identity(
Expand Down
Loading