fix(security): redact sensitive session IDs in marathonctl output

Addresses CodeQL cleartext-logging alerts (#1, #2, #3) by implementing
session ID redaction for CLI output.

Changes:
- Extract marathonctl into standalone crate (crates/marathonctl)
- Add session ID redaction showing only first 8 characters by default
- Add --show-sensitive/-s flag for full session IDs when debugging
- Implement beautiful ratatui-based UI module with inline viewport
- Add .envrc to .gitignore for secure token management
- Document GitHub token setup in CONTRIBUTING.md

The CLI now provides a secure-by-default experience while maintaining
debugging capabilities through explicit opt-in flags. Session IDs are
redacted to format "abc-def-..." unless --show-sensitive is specified.

UI module provides easy-to-use builder APIs (ui::table, ui::grid, ui::list)
that render beautiful terminal output without hijacking the terminal.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:05:16 +00:00
parent 7292aa54e6
commit 25550e2165
8 changed files with 589 additions and 34 deletions

View File

@@ -38,6 +38,8 @@ futures-lite.workspace = true
bytes.workspace = true
crossbeam-channel.workspace = true
clap.workspace = true
ratatui = "0.29"
crossterm = "0.28"
[target.'cfg(target_os = "ios")'.dependencies]
objc = "0.2"

View File

@@ -1,226 +0,0 @@
//! Marathon control CLI
//!
//! Send control commands to a running Marathon instance via Unix domain socket.
//!
//! # Usage
//!
//! ```bash
//! # Get session status
//! marathonctl status
//!
//! # Start networking with a session
//! marathonctl start <session-code>
//!
//! # Use custom socket
//! marathonctl --socket /tmp/marathon1.sock status
//! ```
use clap::{Parser, Subcommand};
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use libmarathon::networking::{ControlCommand, ControlResponse};
/// Marathon control CLI
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Path to the control socket
#[arg(long, default_value = "/tmp/marathon-control.sock")]
socket: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Start networking with a session
Start {
/// Session code (e.g., abc-def-123)
session_code: String,
},
/// Stop networking
Stop,
/// Get current session status
Status,
/// Send a test message
Test {
/// Message content
content: String,
},
/// Broadcast a ping message
Ping,
/// Spawn an entity
Spawn {
/// Entity type (e.g., "cube")
entity_type: String,
/// X position
#[arg(short, long, default_value = "0.0")]
x: f32,
/// Y position
#[arg(short, long, default_value = "0.0")]
y: f32,
/// Z position
#[arg(short, long, default_value = "0.0")]
z: f32,
},
/// Delete an entity by UUID
Delete {
/// Entity UUID
entity_id: String,
},
}
fn main() {
let args = Args::parse();
// Build command from subcommand
let command = match args.command {
Commands::Start { session_code } => ControlCommand::JoinSession { session_code },
Commands::Stop => ControlCommand::LeaveSession,
Commands::Status => ControlCommand::GetStatus,
Commands::Test { content } => ControlCommand::SendTestMessage { content },
Commands::Ping => {
use libmarathon::networking::{SyncMessage, VectorClock};
use uuid::Uuid;
// For ping, we send a SyncRequest (lightweight ping-like message)
let node_id = Uuid::new_v4();
ControlCommand::BroadcastMessage {
message: SyncMessage::SyncRequest {
node_id,
vector_clock: VectorClock::new(),
},
}
}
Commands::Spawn { entity_type, x, y, z } => {
ControlCommand::SpawnEntity {
entity_type,
position: [x, y, z],
}
}
Commands::Delete { entity_id } => {
use uuid::Uuid;
match Uuid::parse_str(&entity_id) {
Ok(uuid) => ControlCommand::DeleteEntity { entity_id: uuid },
Err(e) => {
eprintln!("Invalid UUID '{}': {}", entity_id, e);
std::process::exit(1);
}
}
}
};
// Connect to Unix socket
let socket_path = &args.socket;
let mut stream = match UnixStream::connect(&socket_path) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to connect to {}: {}", socket_path, e);
eprintln!("Is the Marathon app running?");
std::process::exit(1);
}
};
// Send command
if let Err(e) = send_command(&mut stream, &command) {
eprintln!("Failed to send command: {}", e);
std::process::exit(1);
}
// Receive response
match receive_response(&mut stream) {
Ok(response) => {
print_response(response);
}
Err(e) => {
eprintln!("Failed to receive response: {}", e);
std::process::exit(1);
}
}
}
fn send_command(stream: &mut UnixStream, command: &ControlCommand) -> Result<(), Box<dyn std::error::Error>> {
let bytes = command.to_bytes()?;
let len = bytes.len() as u32;
// Write length prefix
stream.write_all(&len.to_le_bytes())?;
// Write command bytes
stream.write_all(&bytes)?;
stream.flush()?;
Ok(())
}
fn receive_response(stream: &mut UnixStream) -> Result<ControlResponse, Box<dyn std::error::Error>> {
// Read length prefix
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
// Read response bytes
let mut response_buf = vec![0u8; len];
stream.read_exact(&mut response_buf)?;
// Deserialize response
let response = ControlResponse::from_bytes(&response_buf)?;
Ok(response)
}
fn print_response(response: ControlResponse) {
match response {
ControlResponse::Status {
node_id,
session_id,
outgoing_queue_size,
incoming_queue_size,
connected_peers,
} => {
println!("Session Status:");
println!(" Node ID: {}", node_id);
println!(" Session: {}", session_id);
println!(" Outgoing Queue: {} messages", outgoing_queue_size);
println!(" Incoming Queue: {} messages", incoming_queue_size);
if let Some(peers) = connected_peers {
println!(" Connected Peers: {}", peers);
}
}
ControlResponse::SessionInfo(info) => {
println!("Session Info:");
println!(" ID: {}", info.session_id);
if let Some(ref name) = info.session_name {
println!(" Name: {}", name);
}
println!(" State: {:?}", info.state);
println!(" Entities: {}", info.entity_count);
println!(" Created: {}", info.created_at);
println!(" Last Active: {}", info.last_active);
}
ControlResponse::Sessions(sessions) => {
println!("Sessions ({} total):", sessions.len());
for session in sessions {
println!(" {}: {:?} ({} entities)", session.session_id, session.state, session.entity_count);
}
}
ControlResponse::Peers(peers) => {
println!("Connected Peers ({} total):", peers.len());
for peer in peers {
print!(" {}", peer.node_id);
if let Some(since) = peer.connected_since {
println!(" (connected since: {})", since);
} else {
println!();
}
}
}
ControlResponse::Ok { message } => {
println!("Success: {}", message);
}
ControlResponse::Error { error } => {
eprintln!("Error: {}", error);
std::process::exit(1);
}
}
}