Scaffold dd0c/run: Rust agent (classifier, executor, audit) + TypeScript SaaS

- Rust agent: clap CLI, command classifier (read-only/modifying/destructive), executor with approval gates, audit log entries
- Classifier: pattern-based safety classification for shell, AWS, kubectl, terraform/tofu commands
- 6 Rust tests: read-only, destructive, modifying, empty, terraform apply, tofu destroy
- SaaS backend: Fastify server, runbook CRUD API, approval API, Slack interactive handler
- Slack integration: signature verification, block_actions for approve/reject buttons
- PostgreSQL schema with RLS: runbooks, executions, audit_entries (append-only), agents
- Dual Dockerfiles: Rust multi-stage (agent), Node multi-stage (SaaS)
- Gitea Actions CI: Rust test+clippy, Node typecheck+test
- Fly.io config for SaaS
This commit is contained in:
2026-03-01 03:03:29 +00:00
parent 6f692fc5ef
commit 57e7083986
18 changed files with 1046 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use uuid::Uuid;
/// Immutable, append-only audit log entry.
/// Every command execution gets logged — no exceptions (BMad must-have).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: String,
pub tenant_id: String,
pub runbook_id: String,
pub step_index: usize,
pub command: String,
pub safety_level: String,
pub approved_by: Option<String>,
pub approval_method: Option<String>, // "slack_button", "api", "auto" (read-only only)
pub exit_code: Option<i32>,
pub stdout_hash: Option<String>, // SHA-256 of stdout (don't store raw output)
pub stderr_hash: Option<String>,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub duration_ms: Option<u64>,
pub status: AuditStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuditStatus {
Pending,
AwaitingApproval,
Approved,
Rejected,
Executing,
Completed,
Failed,
TimedOut,
}
impl AuditEntry {
pub fn new(tenant_id: &str, runbook_id: &str, step_index: usize, command: &str, safety_level: &str) -> Self {
Self {
id: Uuid::new_v4().to_string(),
tenant_id: tenant_id.to_string(),
runbook_id: runbook_id.to_string(),
step_index,
command: command.to_string(),
safety_level: safety_level.to_string(),
approved_by: None,
approval_method: None,
exit_code: None,
stdout_hash: None,
stderr_hash: None,
started_at: Utc::now(),
completed_at: None,
duration_ms: None,
status: AuditStatus::Pending,
}
}
}

View File

@@ -0,0 +1,254 @@
use serde::{Deserialize, Serialize};
/// Command safety classification.
/// No full-auto mode — destructive commands ALWAYS require human approval (BMad must-have).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SafetyLevel {
/// Read-only commands (ls, cat, kubectl get, aws describe-*)
ReadOnly,
/// Modifying but recoverable (restart service, scale replicas)
Modifying,
/// Destructive / irreversible (rm, drop, terminate, delete)
Destructive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Classification {
pub command: String,
pub safety: SafetyLevel,
pub requires_approval: bool,
pub reason: String,
pub matched_pattern: Option<String>,
}
/// Classify a shell command by safety level.
/// Uses pattern matching (not regex on raw strings — BMad review finding).
pub fn classify(command: &str) -> Classification {
let trimmed = command.trim();
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.is_empty() {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::ReadOnly,
requires_approval: false,
reason: "Empty command".into(),
matched_pattern: None,
};
}
let base_cmd = tokens[0];
// --- Destructive patterns (always require approval) ---
let destructive_patterns: &[(&str, &str)] = &[
("rm", "file deletion"),
("rmdir", "directory deletion"),
("dd", "raw disk write"),
("mkfs", "filesystem format"),
("fdisk", "partition table modification"),
("shutdown", "system shutdown"),
("reboot", "system reboot"),
("kill", "process termination"),
("pkill", "process termination"),
("killall", "process termination"),
];
for (pattern, reason) in destructive_patterns {
if base_cmd == *pattern {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::Destructive,
requires_approval: true,
reason: reason.to_string(),
matched_pattern: Some(pattern.to_string()),
};
}
}
// AWS destructive
if base_cmd == "aws" {
let has_destructive = tokens.iter().any(|t| {
matches!(*t, "terminate-instances" | "delete-stack" | "delete-bucket"
| "delete-table" | "delete-function" | "delete-cluster"
| "delete-service" | "deregister-task-definition"
| "delete-db-instance" | "delete-db-cluster")
});
if has_destructive {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::Destructive,
requires_approval: true,
reason: "AWS resource deletion".into(),
matched_pattern: Some("aws delete/terminate".into()),
};
}
}
// kubectl destructive
if base_cmd == "kubectl" {
let has_destructive = tokens.iter().any(|t| {
matches!(*t, "delete" | "drain" | "cordon" | "taint")
});
if has_destructive {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::Destructive,
requires_approval: true,
reason: "Kubernetes destructive operation".into(),
matched_pattern: Some("kubectl delete/drain".into()),
};
}
}
// terraform destructive
if base_cmd == "terraform" || base_cmd == "tofu" {
let has_destructive = tokens.iter().any(|t| {
matches!(*t, "destroy" | "apply")
});
if has_destructive {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::Destructive,
requires_approval: true,
reason: "Infrastructure state change".into(),
matched_pattern: Some("terraform destroy/apply".into()),
};
}
}
// --- Modifying patterns (approval recommended) ---
let modifying_patterns: &[(&str, &str)] = &[
("systemctl", "service management"),
("service", "service management"),
("docker", "container management"),
("podman", "container management"),
("chmod", "permission change"),
("chown", "ownership change"),
("mv", "file move/rename"),
("cp", "file copy"),
("sed", "in-place file edit"),
("tee", "file write"),
];
for (pattern, reason) in modifying_patterns {
if base_cmd == *pattern {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::Modifying,
requires_approval: true,
reason: reason.to_string(),
matched_pattern: Some(pattern.to_string()),
};
}
}
// AWS modifying
if base_cmd == "aws" {
let has_modifying = tokens.iter().any(|t| {
matches!(*t, "update-service" | "update-function-code" | "put-item"
| "create-stack" | "update-stack" | "run-instances"
| "stop-instances" | "start-instances" | "scale")
});
if has_modifying {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::Modifying,
requires_approval: true,
reason: "AWS resource modification".into(),
matched_pattern: Some("aws modify".into()),
};
}
}
// kubectl modifying
if base_cmd == "kubectl" {
let has_modifying = tokens.iter().any(|t| {
matches!(*t, "apply" | "patch" | "scale" | "rollout" | "edit" | "label" | "annotate")
});
if has_modifying {
return Classification {
command: trimmed.to_string(),
safety: SafetyLevel::Modifying,
requires_approval: true,
reason: "Kubernetes resource modification".into(),
matched_pattern: Some("kubectl modify".into()),
};
}
}
// --- Read-only (default) ---
Classification {
command: trimmed.to_string(),
safety: SafetyLevel::ReadOnly,
requires_approval: false,
reason: "No destructive or modifying patterns detected".into(),
matched_pattern: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_only_commands() {
let cases = vec!["ls -la", "cat /etc/hosts", "kubectl get pods", "aws s3 ls", "echo hello"];
for cmd in cases {
let result = classify(cmd);
assert_eq!(result.safety, SafetyLevel::ReadOnly, "Expected ReadOnly for: {}", cmd);
assert!(!result.requires_approval);
}
}
#[test]
fn test_destructive_commands() {
let cases = vec![
"rm -rf /tmp/data",
"aws ec2 terminate-instances --instance-ids i-123",
"kubectl delete pod nginx",
"terraform destroy",
"kill -9 1234",
"dd if=/dev/zero of=/dev/sda",
];
for cmd in cases {
let result = classify(cmd);
assert_eq!(result.safety, SafetyLevel::Destructive, "Expected Destructive for: {}", cmd);
assert!(result.requires_approval);
}
}
#[test]
fn test_modifying_commands() {
let cases = vec![
"systemctl restart nginx",
"docker restart my-container",
"chmod 755 script.sh",
"aws ecs update-service --cluster prod --service api",
"kubectl apply -f deployment.yaml",
];
for cmd in cases {
let result = classify(cmd);
assert_eq!(result.safety, SafetyLevel::Modifying, "Expected Modifying for: {}", cmd);
assert!(result.requires_approval);
}
}
#[test]
fn test_empty_command() {
let result = classify("");
assert_eq!(result.safety, SafetyLevel::ReadOnly);
}
#[test]
fn test_terraform_apply_is_destructive() {
let result = classify("terraform apply -auto-approve");
assert_eq!(result.safety, SafetyLevel::Destructive);
assert!(result.requires_approval);
}
#[test]
fn test_tofu_destroy_is_destructive() {
let result = classify("tofu destroy");
assert_eq!(result.safety, SafetyLevel::Destructive);
}
}

View File

@@ -0,0 +1,94 @@
use serde::{Deserialize, Serialize};
use crate::classifier::{classify, SafetyLevel};
use crate::audit::{AuditEntry, AuditStatus};
/// Execution result for a single step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub step_index: usize,
pub command: String,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
pub status: StepStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StepStatus {
Success,
Failed,
Skipped,
AwaitingApproval,
Rejected,
TimedOut,
}
/// Execute a command after classification and approval check.
/// Destructive commands are NEVER auto-approved (BMad must-have).
pub async fn execute_step(
command: &str,
dry_run: bool,
approval_callback: &dyn Fn(&str, SafetyLevel) -> bool,
) -> StepResult {
let classification = classify(command);
// Approval gate
if classification.requires_approval {
let approved = approval_callback(command, classification.safety);
if !approved {
return StepResult {
step_index: 0,
command: command.to_string(),
exit_code: -1,
stdout: String::new(),
stderr: "Approval denied".into(),
duration_ms: 0,
status: StepStatus::Rejected,
};
}
}
if dry_run {
return StepResult {
step_index: 0,
command: command.to_string(),
exit_code: 0,
stdout: format!("[DRY RUN] Would execute: {}", command),
stderr: String::new(),
duration_ms: 0,
status: StepStatus::Skipped,
};
}
// Execute via tokio::process
let start = std::time::Instant::now();
let output = tokio::process::Command::new("sh")
.arg("-c")
.arg(command)
.output()
.await;
let duration = start.elapsed().as_millis() as u64;
match output {
Ok(out) => StepResult {
step_index: 0,
command: command.to_string(),
exit_code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
duration_ms: duration,
status: if out.status.success() { StepStatus::Success } else { StepStatus::Failed },
},
Err(e) => StepResult {
step_index: 0,
command: command.to_string(),
exit_code: -1,
stdout: String::new(),
stderr: e.to_string(),
duration_ms: duration,
status: StepStatus::Failed,
},
}
}

View File

@@ -0,0 +1,85 @@
use clap::{Parser, Subcommand};
use tracing::info;
mod parser;
mod classifier;
mod executor;
mod audit;
#[derive(Parser)]
#[command(name = "dd0c-run", version, about = "Runbook automation agent")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Execute a runbook
Run {
/// Path to runbook file (YAML/Markdown)
#[arg(short, long)]
runbook: String,
/// dd0c SaaS endpoint
#[arg(long, default_value = "https://api.dd0c.dev")]
endpoint: String,
/// API key
#[arg(long, env = "DD0C_API_KEY")]
api_key: String,
/// Dry run (classify only, don't execute)
#[arg(long)]
dry_run: bool,
},
/// Classify a single command
Classify {
/// Command to classify
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
Version,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "dd0c_run=info".into()),
)
.json()
.init();
let cli = Cli::parse();
match cli.command {
Commands::Run { runbook, endpoint, api_key, dry_run } => {
info!(runbook = %runbook, dry_run, "Starting runbook execution");
// TODO: Parse runbook → classify steps → execute with approval gates
}
Commands::Classify { command } => {
let result = classifier::classify(&command);
println!("{}", serde_json::to_string_pretty(&result)?);
}
Commands::Verify { sig, pubkey } => {
// TODO: Ed25519 signature verification
println!("Signature verification not yet implemented");
}
Commands::Version => {
println!("dd0c/run agent v{}", env!("CARGO_PKG_VERSION"));
}
}
Ok(())
}

View File

@@ -0,0 +1,52 @@
use serde::{Deserialize, Serialize};
/// Parsed runbook step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunbookStep {
pub index: usize,
pub description: String,
pub command: String,
pub timeout_seconds: u64,
pub on_failure: FailureAction,
pub condition: Option<String>, // Optional: only run if previous step output matches
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FailureAction {
Abort,
Continue,
Retry { max_attempts: u32 },
}
/// Parsed runbook.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Runbook {
pub name: String,
pub description: String,
pub version: String,
pub steps: Vec<RunbookStep>,
}
/// Parse a YAML runbook into structured steps.
pub fn parse_yaml(content: &str) -> anyhow::Result<Runbook> {
// TODO: Full YAML parsing with serde_yaml
// For now, return a placeholder
Ok(Runbook {
name: "placeholder".into(),
description: "TODO: implement YAML parser".into(),
version: "0.1.0".into(),
steps: vec![],
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_empty_returns_placeholder() {
let result = parse_yaml("").unwrap();
assert_eq!(result.name, "placeholder");
assert!(result.steps.is_empty());
}
}