fix(core,mcp,security)!: resolve critical P0/P1 issues

BREAKING CHANGES:
- owlen-core no longer depends on ratatui/crossterm
- RemoteMcpClient constructors are now async
- MCP path validation is stricter (security hardening)

This commit resolves three critical issues identified in project analysis:

## P0-1: Extract TUI dependencies from owlen-core

Create owlen-ui-common crate to hold UI-agnostic color and theme
abstractions, removing architectural boundary violation.

Changes:
- Create new owlen-ui-common crate with abstract Color enum
- Move theme.rs from owlen-core to owlen-ui-common
- Define Color with Rgb and Named variants (no ratatui dependency)
- Create color conversion layer in owlen-tui (color_convert.rs)
- Update 35+ color usages with conversion wrappers
- Remove ratatui/crossterm from owlen-core dependencies

Benefits:
- owlen-core usable in headless/CLI contexts
- Enables future GUI frontends
- Reduces binary size for core library consumers

## P0-2: Fix blocking WebSocket connections

Convert RemoteMcpClient constructors to async, eliminating runtime
blocking that froze TUI for 30+ seconds on slow connections.

Changes:
- Make new_with_runtime(), new_with_config(), new() async
- Remove block_in_place wrappers for I/O operations
- Add 30-second connection timeout with tokio::time::timeout
- Update 15+ call sites across 10 files to await constructors
- Convert 4 test functions to #[tokio::test]

Benefits:
- TUI remains responsive during WebSocket connections
- Proper async I/O follows Rust best practices
- No more indefinite hangs

## P1-1: Secure path traversal vulnerabilities

Implement comprehensive path validation with 7 defense layers to
prevent file access outside workspace boundaries.

Changes:
- Create validate_safe_path() with multi-layer security:
  * URL decoding (prevents %2E%2E bypasses)
  * Absolute path rejection
  * Null byte protection
  * Windows-specific checks (UNC/device paths)
  * Lexical path cleaning (removes .. components)
  * Symlink resolution via canonicalization
  * Boundary verification with starts_with check
- Update 4 MCP resource functions (get/list/write/delete)
- Add 11 comprehensive security tests

Benefits:
- Blocks URL-encoded, absolute, UNC path attacks
- Prevents null byte injection
- Stops symlink escape attempts
- Cross-platform security (Windows/Linux/macOS)

## Test Results

- owlen-core: 109/109 tests pass (100%)
- owlen-tui: 52/53 tests pass (98%, 1 pre-existing failure)
- owlen-providers: 2/2 tests pass (100%)
- Build: cargo build --all succeeds

## Verification

- ✓ cargo tree -p owlen-core shows no TUI dependencies
- ✓ No block_in_place calls remain in MCP I/O code
- ✓ All 11 security tests pass

Fixes: #P0-1, #P0-2, #P1-1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-29 12:31:20 +01:00
parent 7aa80fb0a4
commit 0728262a9e
31 changed files with 5121 additions and 1166 deletions

View File

@@ -0,0 +1,18 @@
[package]
name = "owlen-ui-common"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
description = "UI-agnostic color and theme abstractions for OWLEN"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
shellexpand = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]

View File

@@ -0,0 +1,206 @@
//! UI-agnostic color abstraction for OWLEN
//!
//! This module provides a color type that can be used across different UI
//! implementations (TUI, GUI, etc.) without tying the core library to any
//! specific rendering framework.
use serde::{Deserialize, Serialize};
/// An abstract color representation that can be converted to different UI frameworks
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Color {
/// RGB color with red, green, and blue components (0-255)
Rgb(u8, u8, u8),
/// Named ANSI color
Named(NamedColor),
}
/// Standard ANSI color names
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NamedColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
Gray,
DarkGray,
LightRed,
LightGreen,
LightYellow,
LightBlue,
LightMagenta,
LightCyan,
White,
}
impl Color {
/// Create an RGB color
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Color::Rgb(r, g, b)
}
/// Create a named color
pub const fn named(color: NamedColor) -> Self {
Color::Named(color)
}
/// Convenience constructors for common colors
pub const fn black() -> Self {
Color::Named(NamedColor::Black)
}
pub const fn white() -> Self {
Color::Named(NamedColor::White)
}
pub const fn red() -> Self {
Color::Named(NamedColor::Red)
}
pub const fn green() -> Self {
Color::Named(NamedColor::Green)
}
pub const fn yellow() -> Self {
Color::Named(NamedColor::Yellow)
}
pub const fn blue() -> Self {
Color::Named(NamedColor::Blue)
}
pub const fn magenta() -> Self {
Color::Named(NamedColor::Magenta)
}
pub const fn cyan() -> Self {
Color::Named(NamedColor::Cyan)
}
pub const fn gray() -> Self {
Color::Named(NamedColor::Gray)
}
pub const fn dark_gray() -> Self {
Color::Named(NamedColor::DarkGray)
}
pub const fn light_red() -> Self {
Color::Named(NamedColor::LightRed)
}
pub const fn light_green() -> Self {
Color::Named(NamedColor::LightGreen)
}
pub const fn light_yellow() -> Self {
Color::Named(NamedColor::LightYellow)
}
pub const fn light_blue() -> Self {
Color::Named(NamedColor::LightBlue)
}
pub const fn light_magenta() -> Self {
Color::Named(NamedColor::LightMagenta)
}
pub const fn light_cyan() -> Self {
Color::Named(NamedColor::LightCyan)
}
}
/// Parse a color from a string representation
///
/// Supports:
/// - Hex colors: "#ff0000", "#FF0000"
/// - Named colors: "red", "lightblue", etc.
pub fn parse_color(s: &str) -> Result<Color, String> {
if let Some(hex) = s.strip_prefix('#')
&& hex.len() == 6
{
let r =
u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
let g =
u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
let b =
u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
return Ok(Color::Rgb(r, g, b));
}
// Try named colors
match s.to_lowercase().as_str() {
"black" => Ok(Color::Named(NamedColor::Black)),
"red" => Ok(Color::Named(NamedColor::Red)),
"green" => Ok(Color::Named(NamedColor::Green)),
"yellow" => Ok(Color::Named(NamedColor::Yellow)),
"blue" => Ok(Color::Named(NamedColor::Blue)),
"magenta" => Ok(Color::Named(NamedColor::Magenta)),
"cyan" => Ok(Color::Named(NamedColor::Cyan)),
"gray" | "grey" => Ok(Color::Named(NamedColor::Gray)),
"darkgray" | "darkgrey" => Ok(Color::Named(NamedColor::DarkGray)),
"lightred" => Ok(Color::Named(NamedColor::LightRed)),
"lightgreen" => Ok(Color::Named(NamedColor::LightGreen)),
"lightyellow" => Ok(Color::Named(NamedColor::LightYellow)),
"lightblue" => Ok(Color::Named(NamedColor::LightBlue)),
"lightmagenta" => Ok(Color::Named(NamedColor::LightMagenta)),
"lightcyan" => Ok(Color::Named(NamedColor::LightCyan)),
"white" => Ok(Color::Named(NamedColor::White)),
_ => Err(format!("Unknown color: {}", s)),
}
}
/// Convert a color to its string representation
pub fn color_to_string(color: &Color) -> String {
match color {
Color::Named(NamedColor::Black) => "black".to_string(),
Color::Named(NamedColor::Red) => "red".to_string(),
Color::Named(NamedColor::Green) => "green".to_string(),
Color::Named(NamedColor::Yellow) => "yellow".to_string(),
Color::Named(NamedColor::Blue) => "blue".to_string(),
Color::Named(NamedColor::Magenta) => "magenta".to_string(),
Color::Named(NamedColor::Cyan) => "cyan".to_string(),
Color::Named(NamedColor::Gray) => "gray".to_string(),
Color::Named(NamedColor::DarkGray) => "darkgray".to_string(),
Color::Named(NamedColor::LightRed) => "lightred".to_string(),
Color::Named(NamedColor::LightGreen) => "lightgreen".to_string(),
Color::Named(NamedColor::LightYellow) => "lightyellow".to_string(),
Color::Named(NamedColor::LightBlue) => "lightblue".to_string(),
Color::Named(NamedColor::LightMagenta) => "lightmagenta".to_string(),
Color::Named(NamedColor::LightCyan) => "lightcyan".to_string(),
Color::Named(NamedColor::White) => "white".to_string(),
Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_parsing() {
assert_eq!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0)));
assert_eq!(parse_color("red"), Ok(Color::Named(NamedColor::Red)));
assert_eq!(
parse_color("lightblue"),
Ok(Color::Named(NamedColor::LightBlue))
);
assert!(parse_color("invalid").is_err());
}
#[test]
fn test_color_to_string() {
assert_eq!(color_to_string(&Color::Named(NamedColor::Red)), "red");
assert_eq!(color_to_string(&Color::Rgb(255, 0, 0)), "#ff0000");
}
#[test]
fn test_color_constructors() {
assert_eq!(Color::black(), Color::Named(NamedColor::Black));
assert_eq!(Color::rgb(255, 0, 0), Color::Rgb(255, 0, 0));
}
}

View File

@@ -0,0 +1,14 @@
//! UI-agnostic abstractions for OWLEN
//!
//! This crate provides color and theme abstractions that can be used across
//! different UI implementations (TUI, GUI, etc.) without tying the core library
//! to any specific rendering framework.
pub mod color;
pub mod theme;
// Re-export commonly used types
pub use color::{Color, NamedColor, color_to_string, parse_color};
pub use theme::{
Theme, ThemePalette, built_in_themes, default_themes_dir, get_theme, load_all_themes,
};

File diff suppressed because it is too large Load Diff