Implement P6 agent Run command: YAML parse → classify → execute with approval gates
- Full runbook execution loop: parse YAML, validate required variables, merge defaults - Variable substitution via --var key=value CLI args - Safety-gated execution: read-only auto-approved, modifying/destructive prompt on stdin - Failure handling: abort, continue, retry with max_attempts - Removed Verify subcommand (Ed25519 deferred to post-V1)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod classifier;
|
pub mod classifier;
|
||||||
@@ -32,25 +33,25 @@ enum Commands {
|
|||||||
/// Dry run (classify only, don't execute)
|
/// Dry run (classify only, don't execute)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
|
||||||
|
/// Variable overrides (key=value)
|
||||||
|
#[arg(short, long, value_parser = parse_var)]
|
||||||
|
var: Vec<(String, String)>,
|
||||||
},
|
},
|
||||||
/// Classify a single command
|
/// Classify a single command
|
||||||
Classify {
|
Classify {
|
||||||
/// Command to classify
|
/// Command to classify
|
||||||
command: String,
|
command: String,
|
||||||
},
|
},
|
||||||
/// Verify agent binary signature
|
|
||||||
Verify {
|
|
||||||
/// Path to signature file
|
|
||||||
#[arg(short, long)]
|
|
||||||
sig: String,
|
|
||||||
/// Path to public key
|
|
||||||
#[arg(short, long)]
|
|
||||||
pubkey: String,
|
|
||||||
},
|
|
||||||
/// Print version
|
/// Print version
|
||||||
Version,
|
Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_var(s: &str) -> Result<(String, String), String> {
|
||||||
|
let pos = s.find('=').ok_or_else(|| format!("invalid KEY=VALUE: no `=` found in `{s}`"))?;
|
||||||
|
Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
@@ -64,18 +65,143 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Run { runbook, endpoint, api_key, dry_run } => {
|
Commands::Run { runbook, endpoint, api_key, dry_run, var } => {
|
||||||
info!(runbook = %runbook, dry_run, "Starting runbook execution");
|
info!(runbook = %runbook, dry_run, "Starting runbook execution");
|
||||||
// TODO: Parse runbook → classify steps → execute with approval gates
|
|
||||||
|
// Parse runbook YAML
|
||||||
|
let content = std::fs::read_to_string(&runbook)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to read runbook '{}': {}", runbook, e))?;
|
||||||
|
let rb = parser::parse_yaml(&content)?;
|
||||||
|
|
||||||
|
info!(name = %rb.name, steps = rb.steps.len(), "Runbook parsed");
|
||||||
|
|
||||||
|
// Build variable map from CLI args
|
||||||
|
let variables: HashMap<String, String> = var.into_iter().collect();
|
||||||
|
|
||||||
|
// Validate required variables
|
||||||
|
for (name, spec) in &rb.variables {
|
||||||
|
if spec.required && !variables.contains_key(name) && spec.default.is_none() {
|
||||||
|
anyhow::bail!("Required variable '{}' not provided (use --var {}=VALUE)", name, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge defaults
|
||||||
|
let mut merged_vars = HashMap::new();
|
||||||
|
for (name, spec) in &rb.variables {
|
||||||
|
if let Some(val) = variables.get(name) {
|
||||||
|
merged_vars.insert(name.clone(), val.clone());
|
||||||
|
} else if let Some(default) = &spec.default {
|
||||||
|
merged_vars.insert(name.clone(), default.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-approve callback for read-only commands; reject everything else in dry-run
|
||||||
|
let approval_callback = |cmd: &str, safety: classifier::SafetyLevel| -> bool {
|
||||||
|
match safety {
|
||||||
|
classifier::SafetyLevel::ReadOnly => {
|
||||||
|
info!(command = %cmd, "Auto-approved (read-only)");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ if dry_run => {
|
||||||
|
info!(command = %cmd, safety = ?safety, "Would require approval (dry run)");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// In real mode, this would wait for SaaS approval via Redis pub/sub
|
||||||
|
// For now, prompt on stdin
|
||||||
|
eprintln!("\n⚠️ Command requires approval ({:?}):", safety);
|
||||||
|
eprintln!(" {}", cmd);
|
||||||
|
eprint!(" Approve? [y/N] ");
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input).unwrap_or(0);
|
||||||
|
input.trim().eq_ignore_ascii_case("y")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute steps
|
||||||
|
let mut failed = false;
|
||||||
|
for step in &rb.steps {
|
||||||
|
let command = parser::substitute_variables(&step.command, &merged_vars)?;
|
||||||
|
let classification = classifier::classify(&command);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
step = step.index,
|
||||||
|
command = %command,
|
||||||
|
safety = ?classification.safety,
|
||||||
|
description = %step.description,
|
||||||
|
"Executing step"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = executor::execute_step(&command, dry_run, &approval_callback).await;
|
||||||
|
|
||||||
|
match result.status {
|
||||||
|
executor::StepStatus::Success => {
|
||||||
|
info!(step = step.index, exit_code = result.exit_code, duration_ms = result.duration_ms, "Step completed");
|
||||||
|
}
|
||||||
|
executor::StepStatus::Skipped => {
|
||||||
|
info!(step = step.index, "Step skipped (dry run)");
|
||||||
|
}
|
||||||
|
executor::StepStatus::Rejected => {
|
||||||
|
info!(step = step.index, "Step rejected");
|
||||||
|
match step.on_failure {
|
||||||
|
parser::FailureAction::Abort => {
|
||||||
|
eprintln!("Step {} rejected — aborting runbook", step.index);
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parser::FailureAction::Continue => {
|
||||||
|
eprintln!("Step {} rejected — continuing", step.index);
|
||||||
|
}
|
||||||
|
parser::FailureAction::Retry { .. } => {
|
||||||
|
eprintln!("Step {} rejected — cannot retry rejection, aborting", step.index);
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
executor::StepStatus::Failed | executor::StepStatus::TimedOut => {
|
||||||
|
eprintln!("Step {} failed (exit code {})", step.index, result.exit_code);
|
||||||
|
if !result.stderr.is_empty() {
|
||||||
|
eprintln!(" stderr: {}", result.stderr.trim());
|
||||||
|
}
|
||||||
|
match &step.on_failure {
|
||||||
|
parser::FailureAction::Abort => {
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parser::FailureAction::Continue => {}
|
||||||
|
parser::FailureAction::Retry { max_attempts } => {
|
||||||
|
let mut retried = false;
|
||||||
|
for attempt in 1..=*max_attempts {
|
||||||
|
info!(step = step.index, attempt, "Retrying");
|
||||||
|
let retry = executor::execute_step(&command, dry_run, &approval_callback).await;
|
||||||
|
if matches!(retry.status, executor::StepStatus::Success) {
|
||||||
|
retried = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !retried {
|
||||||
|
eprintln!("Step {} failed after {} retries — aborting", step.index, max_attempts);
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
info!("Runbook '{}' completed successfully", rb.name);
|
||||||
}
|
}
|
||||||
Commands::Classify { command } => {
|
Commands::Classify { command } => {
|
||||||
let result = classifier::classify(&command);
|
let result = classifier::classify(&command);
|
||||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||||
}
|
}
|
||||||
Commands::Verify { sig, pubkey } => {
|
|
||||||
// TODO: Ed25519 signature verification
|
|
||||||
println!("Signature verification not yet implemented");
|
|
||||||
}
|
|
||||||
Commands::Version => {
|
Commands::Version => {
|
||||||
println!("dd0c/run agent v{}", env!("CARGO_PKG_VERSION"));
|
println!("dd0c/run agent v{}", env!("CARGO_PKG_VERSION"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user