diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2970e67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: ["main", "feat/**", "fix/**", "chore/**"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: cargo check + run: cargo check --workspace --all-targets + + - name: cargo test + run: cargo test --workspace + + - name: cargo clippy + run: cargo clippy --workspace --all-targets -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 87dabb3..2930621 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2381,10 +2381,13 @@ dependencies = [ "mockall", "reqwest", "sentinel-audit", + "sentinel-capabilities", "sentinel-core", + "sentinel-exec", "sentinel-policy", "serde", "serde_json", + "tempfile", "thiserror 1.0.69", "tokio", "tracing", @@ -2513,6 +2516,7 @@ dependencies = [ "sentinel-audit", "sentinel-capabilities", "sentinel-core", + "sentinel-exec", "sentinel-fleet", "sentinel-policy", "serde", diff --git a/sentinel-agent-llm/Cargo.toml b/sentinel-agent-llm/Cargo.toml index 82fbc20..9de884f 100644 --- a/sentinel-agent-llm/Cargo.toml +++ b/sentinel-agent-llm/Cargo.toml @@ -22,3 +22,7 @@ reqwest = { workspace = true } tokio = { workspace = true } mockall = { workspace = true } wiremock = { workspace = true } +sentinel-capabilities = { workspace = true } +sentinel-exec = { workspace = true } +sentinel-policy = { workspace = true } +tempfile = { workspace = true } diff --git a/sentinel-agent-llm/src/reasoning_loop.rs b/sentinel-agent-llm/src/reasoning_loop.rs index 27294d0..d957f07 100644 --- a/sentinel-agent-llm/src/reasoning_loop.rs +++ b/sentinel-agent-llm/src/reasoning_loop.rs @@ -12,6 +12,7 @@ //! 4. **Act** — each plan step is policy-checked and executed in sequence. //! Failures may trigger rollback of completed steps. +use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; @@ -20,7 +21,7 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use sentinel_audit::{AuditEventType, AuditLog}; -use sentinel_core::{ApprovalDecision, ExecutionContext, Plan, StepStatus}; +use sentinel_core::{ApprovalDecision, Capability, ExecutionContext, Plan, StepStatus}; use sentinel_policy::{PolicyEffect, PolicyEvaluator, PolicyRequest}; use crate::backend::{LlmBackend, Message}; @@ -81,6 +82,7 @@ pub struct ExecutionSummary { pub struct ReasoningLoop { backend: Box, capability_registry: Arc, + capability_impls: HashMap>, policy_evaluator: Arc, audit_log: Arc>, config: ReasoningConfig, @@ -98,12 +100,27 @@ impl ReasoningLoop { Self { backend, capability_registry, + capability_impls: HashMap::new(), policy_evaluator, audit_log, config, } } + /// Register concrete capability implementations for real dispatch. + /// + /// Without this, [`invoke_capability`](Self::invoke_capability) falls back + /// to stub results. Supplying real implementations wires the loop to + /// actual capability execution (via the underlying executor). + pub fn with_capabilities(mut self, capabilities: Vec>) -> Self { + self.capability_impls = capabilities + .into_iter() + .map(|cap| (cap.manifest().id.clone(), cap)) + .collect(); + info!(count = self.capability_impls.len(), "capability implementations registered"); + self + } + // ── Investigate phase ───────────────────────────────────────────────────── /// Run the investigation phase. @@ -629,8 +646,17 @@ impl ReasoningLoop { "attempting rollback" ); - // In a real implementation we'd call the capability's inverse(). - // Here we mark the step as rolled back and audit it. + // Invoke the capability's inverse to actually undo the effect. + if let Some(cap) = self.capability_impls.get(&capability_id) { + let rb_ctx = ExecutionContext::new(session_id, host); + match cap.invoke_inverse(plan.steps[step_idx].args.clone(), &rb_ctx).await { + Some(sentinel_core::CapabilityResult::Success { .. }) => info!(capability_id = %capability_id, "rollback succeeded"), + Some(sentinel_core::CapabilityResult::Failure { error, .. }) => warn!(capability_id = %capability_id, error = %error, "rollback failed"), + None => info!(capability_id = %capability_id, "capability has no inverse"), + _ => {} + } + } + plan.steps[step_idx].status = StepStatus::RolledBack; steps_completed -= 1; steps_rolled_back += 1; @@ -692,20 +718,18 @@ impl ReasoningLoop { &self, _session_id: Uuid, capability_id: &str, - _args: &serde_json::Value, - _ctx: &ExecutionContext, + args: &serde_json::Value, + ctx: &ExecutionContext, ) -> Result { - // In a real deployment, this would look up the capability by ID and - // call capability.invoke(args, ctx). For the LLM crate we return a - // stub result so tests can exercise the loop without real capabilities. - debug!( - capability_id = %capability_id, - "stub capability invocation (no capability executor wired up)" - ); - Ok(sentinel_core::CapabilityResult::success(serde_json::json!({ - "stub": true, - "capability_id": capability_id - }))) + if let Some(cap) = self.capability_impls.get(capability_id) { + let result = cap.invoke(args.clone(), ctx).await; + Ok(result) + } else { + debug!(capability_id = %capability_id, "stub invocation — no implementation registered"); + Ok(sentinel_core::CapabilityResult::success(serde_json::json!({ + "stub": true, "capability_id": capability_id + }))) + } } } diff --git a/sentinel-agent-llm/tests/integration_test.rs b/sentinel-agent-llm/tests/integration_test.rs new file mode 100644 index 0000000..d4c11e4 --- /dev/null +++ b/sentinel-agent-llm/tests/integration_test.rs @@ -0,0 +1,428 @@ +//! End-to-end integration tests for the Sentinel agent loop. +//! +//! These tests wire real capability implementations (via a mock executor) +//! to the reasoning loop, verifying the full +//! Investigate → Plan → Approve → Act → Audit path without hitting the OS. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use sentinel_agent_llm::{ + backend::{LlmBackend, LlmResponse, Message}, + error::AgentError, + planner::CapabilityRegistry, + reasoning_loop::{ReasoningConfig, ReasoningLoop}, +}; +use sentinel_audit::AuditLog; +use sentinel_capabilities::all_capabilities; +use sentinel_core::{ApprovalDecision, CapabilityManifest, RiskTier}; +use sentinel_exec::{CommandExecutorTrait, CommandOutput, ExecError}; +use sentinel_policy::{KillSwitch, PolicyEvaluator, PolicyRule, RuleEffect}; +use tokio::sync::Mutex; +use uuid::Uuid; + +// ── Mock LLM backend ────────────────────────────────────────────────────────── + +struct SequentialMockBackend { + responses: Vec, + idx: std::sync::atomic::AtomicUsize, +} + +impl SequentialMockBackend { + fn new(responses: Vec>) -> Self { + Self { + responses: responses.into_iter().map(Into::into).collect(), + idx: Default::default(), + } + } +} + +#[async_trait] +impl LlmBackend for SequentialMockBackend { + fn name(&self) -> &str { + "mock" + } + fn model(&self) -> &str { + "mock-model" + } + + async fn complete( + &self, + _messages: Vec, + _max_tokens: u32, + ) -> Result { + let i = self.idx.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let content = self.responses.get(i).cloned().unwrap_or_else(|| { + r#"{"done_investigating": true, "reasoning": "no more responses"}"#.to_string() + }); + Ok(LlmResponse { + content, + model: "mock-model".to_string(), + input_tokens: 5, + output_tokens: 30, + finish_reason: "end_turn".to_string(), + }) + } + + async fn health_check(&self) -> Result<(), AgentError> { + Ok(()) + } +} + +// ── Instrumented mock executor ──────────────────────────────────────────────── +// +// Records each (program, args) invocation so tests can assert which real +// OS commands the capabilities tried to run. Returns canned stdout per +// program to keep capabilities happy. + +struct RecordingExecutor { + #[allow(clippy::type_complexity)] + calls: Arc)>>>, +} + +impl RecordingExecutor { + #[allow(clippy::type_complexity)] + fn new() -> (Arc, Arc)>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + (Arc::new(Self { calls: Arc::clone(&calls) }), calls) + } +} + +#[async_trait] +impl CommandExecutorTrait for RecordingExecutor { + async fn run( + &self, + program: &str, + args: &[&str], + _env: &HashMap, + _max_output_bytes: usize, + ) -> Result { + let mut guard = self.calls.lock().await; + guard.push((program.to_string(), args.iter().map(|s| s.to_string()).collect())); + + // Return canned success output so capabilities don't fail on parse. + let stdout = match program { + "df" => "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 50G 20G 30G 40% /\n", + "du" => "20G\t/\n", + "ps" => "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 1 0.0 0.1 12345 1234 ? Ss 00:00 0:01 /sbin/init\n", + "systemctl" => "● nginx.service - A high performance web server\n Loaded: loaded\n Active: active (running)\n", + "find" => "", + "which" => "", + _ => "", + }; + + Ok(CommandOutput { + stdout: stdout.to_string(), + stderr: String::new(), + exit_code: Some(0), + truncated: false, + }) + } +} + +// ── Test helpers ────────────────────────────────────────────────────────────── + +fn make_allow_all_policy() -> Arc { + let allow_all = PolicyRule { + id: "allow-all".into(), + name: "Allow All (test)".into(), + description: "Allow everything for integration tests".into(), + effect: RuleEffect::Allow, + conditions: vec![], + priority: 1000, + enabled: true, + }; + Arc::new(PolicyEvaluator::new(vec![allow_all], KillSwitch::new(), vec![])) +} + +fn make_registry_from_caps(caps: &[Box]) -> Arc { + let mut registry = CapabilityRegistry::new(); + for cap in caps { + registry.register(cap.manifest().clone()); + } + Arc::new(registry) +} + +fn fast_config() -> ReasoningConfig { + ReasoningConfig { + max_investigation_rounds: 5, + max_tokens_per_call: 512, + investigation_timeout_ms: 10_000, + } +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +/// Verify that with real capabilities wired, `investigate()` calls real code +/// and returns a non-stub observation. +#[tokio::test] +async fn integration_investigate_calls_real_capability() { + let session_id = Uuid::new_v4(); + let (executor, calls) = RecordingExecutor::new(); + + let caps = all_capabilities(executor); + let registry = make_registry_from_caps(&caps); + let policy = make_allow_all_policy(); + let audit = Arc::new(Mutex::new(AuditLog::new(session_id, None))); + + let backend = SequentialMockBackend::new(vec![ + // Round 1: LLM requests disk_usage + r#"{"capability_id": "disk_usage", "args": {"path": "/"}, "reasoning": "check disk"}"#, + // Round 2: LLM declares done + r#"{"done_investigating": true, "reasoning": "have enough info"}"#, + ]); + + let loop_ = ReasoningLoop::new( + Box::new(backend), + registry, + policy, + Arc::clone(&audit), + fast_config(), + ) + .with_capabilities(caps); + + let observations = loop_ + .investigate(session_id, "Free up disk space on /", "localhost") + .await + .unwrap(); + + // One observation recorded. + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].capability_id, "disk_usage"); + + // Observation should be a real success (not stub). + assert!(observations[0].result.is_success()); + if let sentinel_core::CapabilityResult::Success { output } = &observations[0].result { + // Real DiskUsage returns df_output and du_output, not {"stub": true}. + assert!( + output.get("stub").is_none(), + "result should not be a stub: {output}" + ); + assert!(output.get("df_output").is_some(), "expected df_output in result"); + } + + // Verify the executor was actually called with df and du. + let recorded = calls.lock().await; + let programs: Vec<&str> = recorded.iter().map(|(p, _)| p.as_str()).collect(); + assert!(programs.contains(&"df"), "expected df invocation, got: {programs:?}"); + assert!(programs.contains(&"du"), "expected du invocation, got: {programs:?}"); +} + +/// Verify the full Investigate → Plan → Execute loop end-to-end. +#[tokio::test] +async fn integration_full_loop_investigate_plan_execute() { + let session_id = Uuid::new_v4(); + let (executor, calls) = RecordingExecutor::new(); + + let caps = all_capabilities(executor); + let registry = make_registry_from_caps(&caps); + let policy = make_allow_all_policy(); + let audit = Arc::new(Mutex::new(AuditLog::new(session_id, None))); + + let backend = SequentialMockBackend::new(vec![ + // Investigate round 1: check disk + r#"{"capability_id": "disk_usage", "args": {"path": "/"}, "reasoning": "assess disk"}"#, + // Investigate round 2: done + r#"{"done_investigating": true, "reasoning": "ready to plan"}"#, + // Plan response + r#"{ + "rationale": "Disk is 40% used. Check service status to see if there are logs to vacuum.", + "steps": [ + { + "capability_id": "disk_usage", + "args": {"path": "/var"}, + "description": "Check /var disk usage", + "can_rollback": false, + "depends_on": [] + }, + { + "capability_id": "service_status", + "args": {"service": "nginx"}, + "description": "Check nginx service status", + "can_rollback": false, + "depends_on": [] + } + ] + }"#, + ]); + + let loop_ = ReasoningLoop::new( + Box::new(backend), + registry, + policy, + Arc::clone(&audit), + fast_config(), + ) + .with_capabilities(caps); + + // Investigate + let observations = loop_ + .investigate(session_id, "Diagnose disk usage on /var", "localhost") + .await + .unwrap(); + assert_eq!(observations.len(), 1); + + // Plan + let mut plan = loop_ + .plan(session_id, "Diagnose disk usage on /var", &observations) + .await + .unwrap(); + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].capability_id, "disk_usage"); + assert_eq!(plan.steps[1].capability_id, "service_status"); + + // Execute + let summary = loop_ + .execute_plan(session_id, "localhost", &mut plan, ApprovalDecision::FullApproval) + .await + .unwrap(); + + assert_eq!(summary.steps_completed, 2); + assert_eq!(summary.steps_failed, 0); + assert_eq!(summary.steps_rolled_back, 0); + + // Audit log should have events + let log = audit.lock().await; + assert!(log.event_count() > 0, "audit log should contain events"); + + // Executor should have been called + let recorded = calls.lock().await; + assert!(!recorded.is_empty(), "executor should have been called"); +} + +/// Verify policy denial is correctly observed during investigation. +#[tokio::test] +async fn integration_policy_deny_in_investigation() { + let session_id = Uuid::new_v4(); + let (executor, _) = RecordingExecutor::new(); + + let caps = all_capabilities(executor); + let registry = make_registry_from_caps(&caps); + + // Deny-by-default — no rules + let policy = Arc::new(PolicyEvaluator::new(vec![], KillSwitch::new(), vec![])); + let audit = Arc::new(Mutex::new(AuditLog::new(session_id, None))); + + let backend = SequentialMockBackend::new(vec![ + r#"{"capability_id": "disk_usage", "args": {"path": "/"}, "reasoning": "check disk"}"#, + r#"{"done_investigating": true, "reasoning": "was denied, stopping"}"#, + ]); + + let loop_ = ReasoningLoop::new( + Box::new(backend), + registry, + policy, + audit, + fast_config(), + ) + .with_capabilities(caps); + + let observations = loop_ + .investigate(session_id, "check disk", "localhost") + .await + .unwrap(); + + assert_eq!(observations.len(), 1); + // Observation should be a failure with "Policy denied" in the error. + assert!(observations[0].result.is_failure()); + if let sentinel_core::CapabilityResult::Failure { error, .. } = &observations[0].result { + assert!(error.contains("Policy denied"), "expected policy denial: {error}"); + } +} + +/// Verify the audit log chain is valid after a full execution. +#[tokio::test] +async fn integration_audit_chain_valid_after_execution() { + use sentinel_audit::verifier::AuditVerifier; + + let session_id = Uuid::new_v4(); + let (executor, _) = RecordingExecutor::new(); + + let caps = all_capabilities(executor); + let registry = make_registry_from_caps(&caps); + let policy = make_allow_all_policy(); + + // Use a temp file for the audit log so we can verify it afterward. + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("audit.jsonl"); + let audit = Arc::new(Mutex::new(AuditLog::new(session_id, Some(log_path.clone())))); + + let backend = SequentialMockBackend::new(vec![ + r#"{"done_investigating": true, "reasoning": "quick goal"}"#, + r#"{"rationale": "just check disk", "steps": [{"capability_id": "disk_usage", "args": {"path": "/"}, "description": "check disk", "can_rollback": false, "depends_on": []}]}"#, + ]); + + let loop_ = ReasoningLoop::new( + Box::new(backend), + registry, + policy, + Arc::clone(&audit), + fast_config(), + ) + .with_capabilities(caps); + + let observations = loop_.investigate(session_id, "check disk", "localhost").await.unwrap(); + let mut plan = loop_.plan(session_id, "check disk", &observations).await.unwrap(); + plan.approve(); + loop_.execute_plan(session_id, "localhost", &mut plan, ApprovalDecision::FullApproval).await.unwrap(); + + // Each append is persisted immediately, so the JSONL file is already on disk. + // Read and verify the JSONL chain + let content = std::fs::read_to_string(&log_path).unwrap(); + let result = AuditVerifier::verify_jsonl(&content).unwrap(); + assert!(result.valid, "audit chain should be valid after execution"); + assert!(result.events_checked > 0, "should have checked at least one event"); +} + +/// Verify that 14 capabilities are registered and all have unique IDs. +#[tokio::test] +async fn integration_all_14_capabilities_registered() { + let (executor, _) = RecordingExecutor::new(); + let caps = all_capabilities(executor); + + assert_eq!(caps.len(), 14, "expected exactly 14 capabilities, got {}", caps.len()); + + let mut ids = std::collections::HashSet::new(); + for cap in &caps { + let id = cap.manifest().id.clone(); + assert!(!id.is_empty(), "capability ID must not be empty"); + assert!(ids.insert(id.clone()), "duplicate capability ID: {id}"); + } +} + +/// Verify stub mode still works (no capabilities injected). +#[tokio::test] +async fn integration_stub_mode_still_works() { + let session_id = Uuid::new_v4(); + let mut registry = CapabilityRegistry::new(); + registry.register(CapabilityManifest { + id: "disk_usage".into(), + name: "Disk Usage".into(), + description: "Check disk".into(), + kind: sentinel_core::CapabilityKind::ReadOnly, + risk_tier: RiskTier::Low, + resource_impact: Default::default(), + has_inverse: false, + version: "1.0.0".into(), + }); + let registry = Arc::new(registry); + let policy = make_allow_all_policy(); + let audit = Arc::new(Mutex::new(AuditLog::new(session_id, None))); + + let backend = SequentialMockBackend::new(vec![ + r#"{"capability_id": "disk_usage", "args": {}, "reasoning": "check"}"#, + r#"{"done_investigating": true, "reasoning": "done"}"#, + ]); + + // No .with_capabilities() call — stub mode + let loop_ = ReasoningLoop::new(Box::new(backend), registry, policy, audit, fast_config()); + + let obs = loop_.investigate(session_id, "goal", "localhost").await.unwrap(); + assert_eq!(obs.len(), 1); + assert!(obs[0].result.is_success()); + // Stub result contains {"stub": true} + if let sentinel_core::CapabilityResult::Success { output } = &obs[0].result { + assert_eq!(output["stub"], true, "expected stub result in stub mode"); + } +} diff --git a/sentinel-tui/Cargo.toml b/sentinel-tui/Cargo.toml index aa425aa..af1482a 100644 --- a/sentinel-tui/Cargo.toml +++ b/sentinel-tui/Cargo.toml @@ -14,6 +14,7 @@ sentinel-agent-llm = { workspace = true } sentinel-policy = { workspace = true } sentinel-audit = { workspace = true } sentinel-capabilities = { workspace = true } +sentinel-exec = { workspace = true } sentinel-fleet = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/sentinel-tui/src/event_handler.rs b/sentinel-tui/src/event_handler.rs index 6fa5b27..85029dc 100644 --- a/sentinel-tui/src/event_handler.rs +++ b/sentinel-tui/src/event_handler.rs @@ -134,10 +134,8 @@ fn handle_key(app: &mut App, key: KeyEvent) { app.input_cursor -= 1; } } - KeyCode::Right => { - if app.input_cursor < app.goal_input.len() { - app.input_cursor += 1; - } + KeyCode::Right if app.input_cursor < app.goal_input.len() => { + app.input_cursor += 1; } _ => {} diff --git a/sentinel-tui/src/main.rs b/sentinel-tui/src/main.rs index e70f4c5..f76fae4 100644 --- a/sentinel-tui/src/main.rs +++ b/sentinel-tui/src/main.rs @@ -1,4 +1,5 @@ use std::io; +use std::sync::Arc; use std::time::Duration; use anyhow::Result; @@ -11,6 +12,17 @@ use crossterm::{ use ratatui::prelude::*; use tracing_subscriber::{fmt, EnvFilter}; +use sentinel_agent_llm::{ + AnthropicBackend, CapabilityRegistry, LlmBackend, OpenAiBackend, ReasoningConfig, ReasoningLoop, +}; +use sentinel_audit::AuditLog; +use sentinel_capabilities::all_capabilities; +use sentinel_core::ApprovalDecision; +use sentinel_exec::RealCommandExecutor; +use sentinel_policy::default_policy; +use tokio::sync::Mutex; +use uuid::Uuid; + use sentinel_tui::{ app::App, event_handler::{handle_events, AppEvent}, @@ -57,6 +69,9 @@ enum Commands { /// Enable dry-run mode (no real changes made) #[arg(long)] dry_run: bool, + /// Skip the interactive approval prompt and execute immediately + #[arg(long)] + auto_approve: bool, }, /// List available capabilities Capabilities, @@ -93,13 +108,19 @@ async fn main() -> Result<()> { goal, host, dry_run, + auto_approve, } => { - println!( - "sentinel run: goal='{}' host='{}' dry_run={}", - goal, host, dry_run - ); - println!("Interactive run mode requires an LLM backend."); - println!("Set ANTHROPIC_API_KEY or OPENAI_API_KEY and try again."); + run_agent( + goal, + host, + dry_run, + auto_approve, + &cli.backend, + cli.anthropic_api_key.as_deref(), + cli.openai_api_key.as_deref(), + &cli.model, + ) + .await? } } @@ -154,23 +175,161 @@ async fn run_app( // ── Subcommand handlers ─────────────────────────────────────────────────────── +/// Wire the full agent stack — LLM backend, executor, capabilities, registry, +/// policy, audit log — and drive an investigate → plan → approve → act session. +#[allow(clippy::too_many_arguments)] +async fn run_agent( + goal: String, + host: String, + dry_run: bool, + auto_approve: bool, + backend_name: &str, + anthropic_api_key: Option<&str>, + openai_api_key: Option<&str>, + model: &str, +) -> Result<()> { + let session_id = Uuid::new_v4(); + + // 1. LLM backend. + let backend: Box = match backend_name { + "anthropic" => { + let key = anthropic_api_key.ok_or_else(|| { + anyhow::anyhow!("ANTHROPIC_API_KEY is required for the anthropic backend") + })?; + Box::new(AnthropicBackend::new(key.to_string(), model.to_string())) + } + "openai" => { + let key = openai_api_key.ok_or_else(|| { + anyhow::anyhow!("OPENAI_API_KEY is required for the openai backend") + })?; + Box::new(OpenAiBackend::new(key.to_string(), model.to_string())) + } + other => { + return Err(anyhow::anyhow!( + "unknown backend '{other}'; expected 'anthropic' or 'openai'" + )) + } + }; + + // 2. Executor + real capability implementations. + let executor = Arc::new(RealCommandExecutor); + let caps = all_capabilities(executor); + + // 3. Registry of capability manifests (for prompt/planning). + let mut registry = CapabilityRegistry::new(); + for cap in &caps { + registry.register(cap.manifest().clone()); + } + let registry = Arc::new(registry); + + // 4. Policy + audit log (persisted to a per-session JSONL file). + let policy = Arc::new(default_policy()); + let audit_path = std::path::PathBuf::from(format!("sentinel-audit-{session_id}.jsonl")); + let audit = Arc::new(Mutex::new(AuditLog::new(session_id, Some(audit_path.clone())))); + + // 5. Reasoning loop wired with the concrete capabilities. + let agent = ReasoningLoop::new( + backend, + registry, + policy, + Arc::clone(&audit), + ReasoningConfig::default(), + ) + .with_capabilities(caps); + + println!("Sentinel session {session_id}"); + println!("Goal : {goal}"); + println!("Host : {host}"); + println!("Backend : {backend_name} ({model})"); + println!(); + + // Investigate. + println!("── Investigating ──"); + let observations = agent.investigate(session_id, &goal, &host).await?; + println!("Collected {} observation(s).", observations.len()); + + // Plan. + println!("\n── Planning ──"); + let mut plan = agent.plan(session_id, &goal, &observations).await?; + println!("Rationale : {}", plan.rationale); + println!("Overall risk : {:?}", plan.overall_risk); + println!("Steps ({}):", plan.steps.len()); + for (i, step) in plan.steps.iter().enumerate() { + println!( + " {}. [{}] {} (risk {:?})", + i + 1, + step.capability_id, + step.description, + step.risk_tier + ); + } + + if dry_run { + println!("\nDry-run mode: plan generated but NOT executed."); + println!("Audit log written to {}", audit_path.display()); + return Ok(()); + } + + // Approve. + let approval = if auto_approve { + println!("\nAuto-approve enabled — executing plan."); + ApprovalDecision::FullApproval + } else { + use std::io::Write as _; + print!("\nApprove and execute this plan? [y/N] "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if matches!(input.trim().to_lowercase().as_str(), "y" | "yes") { + ApprovalDecision::FullApproval + } else { + ApprovalDecision::Rejected { + reason: "operator declined at the approval prompt".to_string(), + } + } + }; + + if let ApprovalDecision::Rejected { reason } = &approval { + println!("Plan rejected: {reason}"); + println!("Audit log written to {}", audit_path.display()); + return Ok(()); + } + + // Act. + println!("\n── Executing ──"); + let summary = agent + .execute_plan(session_id, &host, &mut plan, approval) + .await?; + println!( + "Done: {} completed, {} failed, {} rolled back in {} ms.", + summary.steps_completed, + summary.steps_failed, + summary.steps_rolled_back, + summary.total_duration_ms + ); + println!("Audit log written to {}", audit_path.display()); + + Ok(()) +} + fn list_capabilities() { - println!("Available capabilities:"); - println!("{:-<60}", ""); - println!(" {:<35} [{:<9}] Risk: Low", "sentinel.fs.read_file", "ReadOnly"); - println!(" Read the contents of a file from the target system."); - println!(" {:<35} [{:<9}] Risk: High", "sentinel.fs.write_file", "Mutating"); - println!(" Write or overwrite a file on the target system."); - println!(" {:<35} [{:<9}] Risk: Low", "sentinel.svc.status", "ReadOnly"); - println!(" Query the status of a systemd service."); - println!(" {:<35} [{:<9}] Risk: Medium", "sentinel.svc.restart", "Mutating"); - println!(" Restart a systemd service."); - println!(" {:<35} [{:<9}] Risk: High", "sentinel.exec.run_command", "Mutating"); - println!(" Execute an arbitrary shell command."); + let executor = Arc::new(RealCommandExecutor); + let caps = all_capabilities(executor); + println!("Available capabilities ({}):", caps.len()); + for cap in &caps { + let m = cap.manifest(); + println!( + " {:<30} [{:?}] Risk: {:?}{}", + m.id, + m.kind, + m.risk_tier, + if m.has_inverse { " [rollback]" } else { "" } + ); + println!(" {}", m.description); + } } fn show_policy() { - use sentinel_policy::default_policy; let _evaluator = default_policy(); println!("Default Sentinel policy (deny-by-default):"); println!("{:-<60}", "");