use crate::io::story_metadata::{TestPlanStatus, parse_front_matter}; use crate::state::SessionState; use serde::Serialize; use std::fs; use std::path::PathBuf; use std::process::Command; /// Helper to get the root path (cloned) without joining fn get_project_root(state: &SessionState) -> Result { state.get_project_root() } async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> { let approved = tokio::task::spawn_blocking(move || { let story_path = root .join(".story_kit") .join("stories") .join("current") .join("26_establish_tdd_workflow_and_gates.md"); let contents = fs::read_to_string(&story_path) .map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?; let metadata = parse_front_matter(&contents) .map_err(|e| format!("Failed to parse story front matter: {e:?}"))?; Ok::(matches!(metadata.test_plan, Some(TestPlanStatus::Approved))) }) .await .map_err(|e| format!("Task failed: {e}"))??; if approved { Ok(()) } else { Err("Test plan is not approved for the current story.".to_string()) } } #[derive(Serialize, Debug, poem_openapi::Object)] pub struct CommandOutput { pub stdout: String, pub stderr: String, pub exit_code: i32, } /// Execute shell command logic (pure function for testing) async fn exec_shell_impl( command: String, args: Vec, root: PathBuf, ) -> Result { // Security Allowlist let allowed_commands = [ "git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm", "mv", "cp", "touch", "rustc", "rustfmt", ]; if !allowed_commands.contains(&command.as_str()) { return Err(format!("Command '{}' is not in the allowlist.", command)); } let output = tokio::task::spawn_blocking(move || { Command::new(&command) .args(&args) .current_dir(root) .output() }) .await .map_err(|e| format!("Task join error: {}", e))? .map_err(|e| format!("Failed to execute command: {}", e))?; Ok(CommandOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), exit_code: output.status.code().unwrap_or(-1), }) } pub async fn exec_shell( command: String, args: Vec, state: &SessionState, ) -> Result { let root = get_project_root(state)?; ensure_test_plan_approved(root.clone()).await?; exec_shell_impl(command, args, root).await } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[tokio::test] async fn exec_shell_requires_approved_test_plan() { let dir = tempdir().expect("tempdir"); let state = SessionState::default(); { let mut root = state.project_root.lock().expect("lock project root"); *root = Some(dir.path().to_path_buf()); } let result = exec_shell("ls".to_string(), Vec::new(), &state).await; assert!( result.is_err(), "expected shell execution to be blocked when test plan is not approved" ); } #[tokio::test] async fn exec_shell_impl_rejects_disallowed_command() { let dir = tempdir().unwrap(); let result = exec_shell_impl( "curl".to_string(), vec!["https://example.com".to_string()], dir.path().to_path_buf(), ) .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("not in the allowlist")); } #[tokio::test] async fn exec_shell_impl_runs_allowed_command() { let dir = tempdir().unwrap(); let result = exec_shell_impl( "ls".to_string(), Vec::new(), dir.path().to_path_buf(), ) .await; assert!(result.is_ok()); let output = result.unwrap(); assert_eq!(output.exit_code, 0); } #[tokio::test] async fn exec_shell_impl_captures_stdout() { let dir = tempdir().unwrap(); std::fs::write(dir.path().join("hello.txt"), "").unwrap(); let result = exec_shell_impl( "ls".to_string(), Vec::new(), dir.path().to_path_buf(), ) .await .unwrap(); assert!(result.stdout.contains("hello.txt")); } #[tokio::test] async fn exec_shell_impl_returns_nonzero_exit_code() { let dir = tempdir().unwrap(); let result = exec_shell_impl( "ls".to_string(), vec!["nonexistent_file_xyz".to_string()], dir.path().to_path_buf(), ) .await .unwrap(); assert_ne!(result.exit_code, 0); assert!(!result.stderr.is_empty()); } }