zeroclaw/src/main.rs
Argenis 0383a82a6f
feat(security): Add Phase 1 security features
* test: add comprehensive recovery tests for agent loop

Add recovery test coverage for all edge cases and failure scenarios
in the agentic loop, addressing the missing test coverage for
recovery use cases.

Tool Call Parsing Edge Cases:
- Empty tool_result tags
- Empty tool_calls arrays
- Whitespace-only tool names
- Empty string arguments

History Management:
- Trimming without system prompt
- Role ordering consistency after trim
- Only system prompt edge case

Arguments Parsing:
- Invalid JSON string fallback
- None arguments handling
- Null value handling

JSON Extraction:
- Empty input handling
- Whitespace only input
- Multiple JSON objects
- JSON arrays

Tool Call Value Parsing:
- Missing name field
- Non-OpenAI format
- Empty tool_calls array
- Missing tool_calls field fallback
- Top-level array format

Constants Validation:
- MAX_TOOL_ITERATIONS bounds (prevent runaway loops)
- MAX_HISTORY_MESSAGES bounds (prevent memory bloat)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(security): Add Phase 1 security features - sandboxing, resource limits, audit logging

Phase 1 security enhancements with zero impact on the quick setup wizard:
-  Pluggable sandbox trait system (traits.rs)
-  Landlock sandbox support (Linux kernel 5.13+)
-  Firejail sandbox support (Linux user-space)
-  Bubblewrap sandbox support (Linux/macOS user namespaces)
-  Docker sandbox support (container isolation)
-  No-op fallback (application-layer security only)
-  Auto-detection logic (detect.rs)
-  Audit logging with HMAC signing support (audit.rs)
-  SecurityConfig schema (SandboxConfig, ResourceLimitsConfig, AuditConfig)
-  Feature-gated implementation (sandbox-landlock, sandbox-bubblewrap)
-  1,265 tests passing

Key design principles:
- Silent auto-detection: no new prompts in wizard
- Graceful degradation: works on all platforms
- Feature flags: zero overhead when disabled
- Pluggable architecture: swap sandbox backends via config
- Backward compatible: existing configs work unchanged

Config usage:
```toml
[security.sandbox]
enabled = false  # Explicitly disable
backend = "auto"  # auto, landlock, firejail, bubblewrap, docker, none

[security.resources]
max_memory_mb = 512
max_cpu_time_seconds = 60

[security.audit]
enabled = true
log_path = "audit.log"
sign_events = false
```

Security documentation:
- docs/sandboxing.md: Sandbox implementation strategies
- docs/resource-limits.md: Resource limit approaches
- docs/audit-logging.md: Audit logging specification
- docs/security-roadmap.md: 3-phase implementation plan
- docs/frictionless-security.md: Zero-impact wizard design
- docs/agnostic-security.md: Platform/hardware agnostic approach

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 04:14:16 -05:00

467 lines
13 KiB
Rust

#![warn(clippy::all, clippy::pedantic)]
#![allow(
clippy::assigning_clones,
clippy::bool_to_int_with_if,
clippy::case_sensitive_file_extension_comparisons,
clippy::cast_possible_wrap,
clippy::doc_markdown,
clippy::field_reassign_with_default,
clippy::float_cmp,
clippy::implicit_clone,
clippy::items_after_statements,
clippy::map_unwrap_or,
clippy::manual_let_else,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::module_name_repetitions,
clippy::needless_pass_by_value,
clippy::needless_raw_string_hashes,
clippy::redundant_closure_for_method_calls,
clippy::similar_names,
clippy::single_match_else,
clippy::struct_field_names,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::unused_self,
clippy::cast_precision_loss,
clippy::unnecessary_cast,
clippy::unnecessary_lazy_evaluations,
clippy::unnecessary_literal_bound,
clippy::unnecessary_map_or,
clippy::unnecessary_wraps,
dead_code
)]
use anyhow::{bail, Result};
use clap::{Parser, Subcommand};
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
mod agent;
mod channels;
mod config;
mod cron;
mod daemon;
mod doctor;
mod gateway;
mod hardware;
mod health;
mod heartbeat;
mod identity;
mod integrations;
mod memory;
mod migration;
mod observability;
mod onboard;
mod providers;
mod runtime;
mod security;
mod service;
mod skillforge;
mod skills;
mod tools;
mod tunnel;
mod util;
use config::Config;
/// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust.
#[derive(Parser, Debug)]
#[command(name = "zeroclaw")]
#[command(author = "theonlyhennygod")]
#[command(version = "0.1.0")]
#[command(about = "The fastest, smallest AI assistant.", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum ServiceCommands {
/// Install daemon service unit for auto-start and restart
Install,
/// Start daemon service
Start,
/// Stop daemon service
Stop,
/// Check daemon service status
Status,
/// Uninstall daemon service unit
Uninstall,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Initialize your workspace and configuration
Onboard {
/// Run the full interactive wizard (default is quick setup)
#[arg(long)]
interactive: bool,
/// Reconfigure channels only (fast repair flow)
#[arg(long)]
channels_only: bool,
/// API key (used in quick mode, ignored with --interactive)
#[arg(long)]
api_key: Option<String>,
/// Provider name (used in quick mode, default: openrouter)
#[arg(long)]
provider: Option<String>,
/// Memory backend (sqlite, markdown, none) - used in quick mode, default: sqlite
#[arg(long)]
memory: Option<String>,
},
/// Start the AI agent loop
Agent {
/// Single message mode (don't enter interactive mode)
#[arg(short, long)]
message: Option<String>,
/// Provider to use (openrouter, anthropic, openai)
#[arg(short, long)]
provider: Option<String>,
/// Model to use
#[arg(long)]
model: Option<String>,
/// Temperature (0.0 - 2.0)
#[arg(short, long, default_value = "0.7")]
temperature: f64,
},
/// Start the gateway server (webhooks, websockets)
Gateway {
/// Port to listen on (use 0 for random available port)
#[arg(short, long, default_value = "8080")]
port: u16,
/// Host to bind to
#[arg(long, default_value = "127.0.0.1")]
host: String,
},
/// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler)
Daemon {
/// Port to listen on (use 0 for random available port)
#[arg(short, long, default_value = "8080")]
port: u16,
/// Host to bind to
#[arg(long, default_value = "127.0.0.1")]
host: String,
},
/// Manage OS service lifecycle (launchd/systemd user service)
Service {
#[command(subcommand)]
service_command: ServiceCommands,
},
/// Run diagnostics for daemon/scheduler/channel freshness
Doctor,
/// Show system status (full details)
Status,
/// Configure and manage scheduled tasks
Cron {
#[command(subcommand)]
cron_command: CronCommands,
},
/// Manage channels (telegram, discord, slack)
Channel {
#[command(subcommand)]
channel_command: ChannelCommands,
},
/// Browse 50+ integrations
Integrations {
#[command(subcommand)]
integration_command: IntegrationCommands,
},
/// Manage skills (user-defined capabilities)
Skills {
#[command(subcommand)]
skill_command: SkillCommands,
},
/// Migrate data from other agent runtimes
Migrate {
#[command(subcommand)]
migrate_command: MigrateCommands,
},
}
#[derive(Subcommand, Debug)]
enum MigrateCommands {
/// Import memory from an `OpenClaw` workspace into this `ZeroClaw` workspace
Openclaw {
/// Optional path to `OpenClaw` workspace (defaults to ~/.openclaw/workspace)
#[arg(long)]
source: Option<std::path::PathBuf>,
/// Validate and preview migration without writing any data
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand, Debug)]
enum CronCommands {
/// List all scheduled tasks
List,
/// Add a new scheduled task
Add {
/// Cron expression
expression: String,
/// Command to run
command: String,
},
/// Remove a scheduled task
Remove {
/// Task ID
id: String,
},
}
#[derive(Subcommand, Debug)]
enum ChannelCommands {
/// List configured channels
List,
/// Start all configured channels (Telegram, Discord, Slack)
Start,
/// Run health checks for configured channels
Doctor,
/// Add a new channel
Add {
/// Channel type
channel_type: String,
/// Configuration JSON
config: String,
},
/// Remove a channel
Remove {
/// Channel name
name: String,
},
}
#[derive(Subcommand, Debug)]
enum SkillCommands {
/// List installed skills
List,
/// Install a skill from a GitHub URL or local path
Install {
/// GitHub URL or local path
source: String,
},
/// Remove an installed skill
Remove {
/// Skill name
name: String,
},
}
#[derive(Subcommand, Debug)]
enum IntegrationCommands {
/// Show details about a specific integration
Info {
/// Integration name
name: String,
},
}
#[tokio::main]
#[allow(clippy::too_many_lines)]
async fn main() -> Result<()> {
// Install default crypto provider for Rustls TLS.
// This prevents the error: "could not automatically determine the process-level CryptoProvider"
// when both aws-lc-rs and ring features are available (or neither is explicitly selected).
if let Err(e) = rustls::crypto::ring::default_provider().install_default() {
eprintln!("Warning: Failed to install default crypto provider: {e:?}");
}
let cli = Cli::parse();
// Initialize logging
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::INFO)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
// Onboard runs quick setup by default, or the interactive wizard with --interactive
if let Commands::Onboard {
interactive,
channels_only,
api_key,
provider,
memory,
} = &cli.command
{
if *interactive && *channels_only {
bail!("Use either --interactive or --channels-only, not both");
}
if *channels_only && (api_key.is_some() || provider.is_some() || memory.is_some()) {
bail!("--channels-only does not accept --api-key, --provider, or --memory");
}
let config = if *channels_only {
onboard::run_channels_repair_wizard()?
} else if *interactive {
onboard::run_wizard()?
} else {
onboard::run_quick_setup(api_key.as_deref(), provider.as_deref(), memory.as_deref())?
};
// Auto-start channels if user said yes during wizard
if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") {
channels::start_channels(config).await?;
}
return Ok(());
}
// All other commands need config loaded first
let mut config = Config::load_or_init()?;
config.apply_env_overrides();
match cli.command {
Commands::Onboard { .. } => unreachable!(),
Commands::Agent {
message,
provider,
model,
temperature,
} => agent::run(config, message, provider, model, temperature).await,
Commands::Gateway { port, host } => {
if port == 0 {
info!("🚀 Starting ZeroClaw Gateway on {host} (random port)");
} else {
info!("🚀 Starting ZeroClaw Gateway on {host}:{port}");
}
gateway::run_gateway(&host, port, config).await
}
Commands::Daemon { port, host } => {
if port == 0 {
info!("🧠 Starting ZeroClaw Daemon on {host} (random port)");
} else {
info!("🧠 Starting ZeroClaw Daemon on {host}:{port}");
}
daemon::run(config, host, port).await
}
Commands::Status => {
println!("🦀 ZeroClaw Status");
println!();
println!("Version: {}", env!("CARGO_PKG_VERSION"));
println!("Workspace: {}", config.workspace_dir.display());
println!("Config: {}", config.config_path.display());
println!();
println!(
"🤖 Provider: {}",
config.default_provider.as_deref().unwrap_or("openrouter")
);
println!(
" Model: {}",
config.default_model.as_deref().unwrap_or("(default)")
);
println!("📊 Observability: {}", config.observability.backend);
println!("🛡️ Autonomy: {:?}", config.autonomy.level);
println!("⚙️ Runtime: {}", config.runtime.kind);
println!(
"💓 Heartbeat: {}",
if config.heartbeat.enabled {
format!("every {}min", config.heartbeat.interval_minutes)
} else {
"disabled".into()
}
);
println!(
"🧠 Memory: {} (auto-save: {})",
config.memory.backend,
if config.memory.auto_save { "on" } else { "off" }
);
println!();
println!("Security:");
println!(" Workspace only: {}", config.autonomy.workspace_only);
println!(
" Allowed commands: {}",
config.autonomy.allowed_commands.join(", ")
);
println!(
" Max actions/hour: {}",
config.autonomy.max_actions_per_hour
);
println!(
" Max cost/day: ${:.2}",
f64::from(config.autonomy.max_cost_per_day_cents) / 100.0
);
println!();
println!("Channels:");
println!(" CLI: ✅ always");
for (name, configured) in [
("Telegram", config.channels_config.telegram.is_some()),
("Discord", config.channels_config.discord.is_some()),
("Slack", config.channels_config.slack.is_some()),
("Webhook", config.channels_config.webhook.is_some()),
] {
println!(
" {name:9} {}",
if configured {
"✅ configured"
} else {
"❌ not configured"
}
);
}
Ok(())
}
Commands::Cron { cron_command } => cron::handle_command(cron_command, &config),
Commands::Service { service_command } => service::handle_command(&service_command, &config),
Commands::Doctor => doctor::run(&config),
Commands::Channel { channel_command } => match channel_command {
ChannelCommands::Start => channels::start_channels(config).await,
ChannelCommands::Doctor => channels::doctor_channels(config).await,
other => channels::handle_command(other, &config),
},
Commands::Integrations {
integration_command,
} => integrations::handle_command(integration_command, &config),
Commands::Skills { skill_command } => {
skills::handle_command(skill_command, &config.workspace_dir)
}
Commands::Migrate { migrate_command } => {
migration::handle_command(migrate_command, &config).await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_has_no_flag_conflicts() {
Cli::command().debug_assert();
}
}