diff --git a/src/adapter/check_adapter.rs b/src/adapter/check_adapter.rs index 9b7f859..6cafb96 100644 --- a/src/adapter/check_adapter.rs +++ b/src/adapter/check_adapter.rs @@ -50,7 +50,7 @@ pub struct CheckAdapter { } impl CheckAdapter { - /// Build from the `--command` CLI argument. + /// Build from positional command arguments. pub fn from_command(command: String) -> Self { Self { command, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a7fba7d..7dc7bbb 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -46,10 +46,6 @@ pub struct ExecArgs { #[derive(clap::Args)] #[cfg_attr(test, derive(Debug, PartialEq))] pub struct CheckArgs { - /// Command string to check (skips stdin) - #[arg(long)] - pub command: Option, - /// Input format: "claude-code-hook" or omit for auto-detection #[arg(long)] pub input_format: Option, @@ -61,6 +57,10 @@ pub struct CheckArgs { /// Output detailed rule matching information to stderr #[arg(long)] pub verbose: bool, + + /// Command and arguments to check (skips stdin) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub command: Vec, } #[derive(clap::ValueEnum, Clone, Debug, PartialEq)] @@ -96,24 +96,24 @@ mod tests { Commands::Exec(ExecArgs { command: vec!["ls".into()], sandbox: None, dry_run: true, verbose: true }), )] #[case::check_with_command( - &["runok", "check", "--command", "git status"], - Commands::Check(CheckArgs { command: Some("git status".into()), input_format: None, output_format: OutputFormat::Text, verbose: false }), + &["runok", "check", "--", "git", "status"], + Commands::Check(CheckArgs { input_format: None, output_format: OutputFormat::Text, verbose: false, command: vec!["git".into(), "status".into()] }), )] #[case::check_with_input_format( &["runok", "check", "--input-format", "claude-code-hook"], - Commands::Check(CheckArgs { command: None, input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false }), + Commands::Check(CheckArgs { input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false, command: vec![] }), )] #[case::check_with_output_format_json( - &["runok", "check", "--output-format", "json", "--command", "ls"], - Commands::Check(CheckArgs { command: Some("ls".into()), input_format: None, output_format: OutputFormat::Json, verbose: false }), + &["runok", "check", "--output-format", "json", "--", "ls"], + Commands::Check(CheckArgs { input_format: None, output_format: OutputFormat::Json, verbose: false, command: vec!["ls".into()] }), )] #[case::check_with_both( - &["runok", "check", "--command", "ls", "--input-format", "claude-code-hook"], - Commands::Check(CheckArgs { command: Some("ls".into()), input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false }), + &["runok", "check", "--input-format", "claude-code-hook", "--", "ls"], + Commands::Check(CheckArgs { input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false, command: vec!["ls".into()] }), )] #[case::check_with_verbose( - &["runok", "check", "--verbose", "--command", "git status"], - Commands::Check(CheckArgs { command: Some("git status".into()), input_format: None, output_format: OutputFormat::Text, verbose: true }), + &["runok", "check", "--verbose", "--", "git", "status"], + Commands::Check(CheckArgs { input_format: None, output_format: OutputFormat::Text, verbose: true, command: vec!["git".into(), "status".into()] }), )] fn cli_parsing(#[case] argv: &[&str], #[case] expected: Commands) { let cli = Cli::parse_from(argv); diff --git a/src/cli/route.rs b/src/cli/route.rs index d733b70..7e0867b 100644 --- a/src/cli/route.rs +++ b/src/cli/route.rs @@ -1,6 +1,7 @@ use crate::adapter::Endpoint; use crate::adapter::check_adapter::{CheckAdapter, CheckInput, OutputFormat}; use crate::adapter::hook_adapter::{ClaudeCodeHookAdapter, HookInput}; +use runok::rules::command_parser::shell_quote_join; use super::CheckArgs; @@ -14,7 +15,7 @@ fn to_adapter_output_format(cli_format: &super::OutputFormat) -> OutputFormat { /// Result of routing `runok check`: either a single endpoint or multiple commands. pub enum CheckRoute { - /// Single endpoint (--command, JSON stdin, or single-line plaintext). + /// Single endpoint (positional command, JSON stdin, or single-line plaintext). Single(Box), /// Multiple commands from multi-line plaintext stdin. Multi(Vec), @@ -27,10 +28,17 @@ pub fn route_check( ) -> Result { let output_format = to_adapter_output_format(&args.output_format); - // 1. --command CLI argument → always generic mode (no stdin) - if let Some(command) = &args.command { + // 1. Positional command arguments → always generic mode (no stdin) + if !args.command.is_empty() { + // Return single arguments unquoted so the rule engine can detect + // shell metacharacters (&&, ;, |) in compound commands. + let command = if args.command.len() == 1 { + args.command[0].clone() + } else { + shell_quote_join(&args.command) + }; return Ok(CheckRoute::Single(Box::new( - CheckAdapter::from_command(command.clone()).with_output_format(output_format), + CheckAdapter::from_command(command).with_output_format(output_format), ))); } @@ -47,14 +55,14 @@ pub fn route_check( return route_json(args, json_value); } - // 4. --format requires JSON; plaintext fallback is not allowed when --format is specified + // 4. --input-format requires JSON; plaintext fallback is not allowed when --input-format is specified if let Some(format) = &args.input_format { return Err(anyhow::anyhow!( "JSON parse error: input must be valid JSON when --input-format '{format}' is specified" )); } - // 5. JSON parse failed, no --format → treat as plaintext (one command per line, skip empty lines) + // 5. JSON parse failed, no --input-format → treat as plaintext (one command per line, skip empty lines) let commands: Vec = stdin_input .lines() .map(|line| line.trim()) @@ -86,7 +94,7 @@ fn route_json( args: &CheckArgs, json_value: serde_json::Value, ) -> Result { - // --format is explicitly specified → use that format + // --input-format is explicitly specified → use that format if let Some(format) = &args.input_format { return match format.as_str() { "claude-code-hook" => { @@ -101,7 +109,7 @@ fn route_json( }; } - // --format omitted → auto-detect by JSON field presence + // --input-format omitted → auto-detect by JSON field presence // HookInput uses #[serde(rename_all = "snake_case")], so the actual JSON key is "tool_name" if json_value.get("tool_name").is_some() { let hook_input: HookInput = serde_json::from_value(json_value)?; @@ -128,12 +136,12 @@ mod tests { use rstest::rstest; /// Helper: build CheckArgs for testing - fn check_args(command: Option<&str>, input_format: Option<&str>) -> CheckArgs { + fn check_args(command: Vec<&str>, input_format: Option<&str>) -> CheckArgs { CheckArgs { - command: command.map(String::from), input_format: input_format.map(String::from), output_format: crate::cli::OutputFormat::Text, verbose: false, + command: command.into_iter().map(String::from).collect(), } } @@ -153,20 +161,21 @@ mod tests { } } - // === route_check: --command flag === + // === route_check: positional command args === #[rstest] - #[case::simple_command("git status")] - #[case::command_with_flags("ls -la /tmp")] - fn route_check_with_command_arg(#[case] cmd: &str) { - let args = check_args(Some(cmd), None); + #[case::simple_command(&["git", "status"], "git status")] + #[case::command_with_flags(&["ls", "-la", "/tmp"], "ls -la /tmp")] + #[case::arg_with_spaces(&["echo", "hello world"], "echo 'hello world'")] + fn route_check_with_command_arg(#[case] cmd: &[&str], #[case] expected: &str) { + let args = check_args(cmd.to_vec(), None); let route = route_check(&args, std::io::empty()); let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}"))); assert_eq!( endpoint .extract_command() .unwrap_or_else(|e| panic!("unexpected error: {e}")), - Some(cmd.to_string()) + Some(expected.to_string()) ); } @@ -193,7 +202,7 @@ mod tests { #[case] stdin_json: &str, #[case] expected_command: Option<&str>, ) { - let args = check_args(None, None); + let args = check_args(vec![], None); let route = route_check(&args, stdin_json.as_bytes()); let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}"))); assert_eq!( @@ -206,7 +215,7 @@ mod tests { #[rstest] fn route_check_stdin_unknown_json_format_returns_error() { - let args = check_args(None, None); + let args = check_args(vec![], None); let result = route_check(&args, r#"{"unknown_field": "value"}"#.as_bytes()); match result { Err(e) => assert!( @@ -217,11 +226,11 @@ mod tests { } } - // === route_check: --format with non-JSON stdin === + // === route_check: --input-format with non-JSON stdin === #[rstest] fn route_check_format_with_non_json_stdin_returns_error() { - let args = check_args(None, Some("claude-code-hook")); + let args = check_args(vec![], Some("claude-code-hook")); let result = route_check(&args, "not valid json".as_bytes()); match result { Err(e) => assert!( @@ -243,7 +252,7 @@ mod tests { #[case] input: &str, #[case] expected_command: &str, ) { - let args = check_args(None, None); + let args = check_args(vec![], None); let route = route_check(&args, input.as_bytes()); let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}"))); assert_eq!( @@ -258,7 +267,7 @@ mod tests { #[rstest] fn route_check_plaintext_single_line() { - let args = check_args(None, None); + let args = check_args(vec![], None); let route = route_check(&args, "git status\n".as_bytes()); let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}"))); assert_eq!( @@ -271,7 +280,7 @@ mod tests { #[rstest] fn route_check_plaintext_multi_line() { - let args = check_args(None, None); + let args = check_args(vec![], None); let input = indoc! {" git status ls -la @@ -291,7 +300,7 @@ mod tests { #[rstest] fn route_check_plaintext_skips_empty_lines() { - let args = check_args(None, None); + let args = check_args(vec![], None); let input = indoc! {" git status @@ -312,7 +321,7 @@ mod tests { #[rstest] fn route_check_plaintext_trims_whitespace() { - let args = check_args(None, None); + let args = check_args(vec![], None); let route = route_check(&args, " git status \n".as_bytes()); let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}"))); assert_eq!( @@ -325,7 +334,7 @@ mod tests { #[rstest] fn route_check_empty_stdin_returns_error() { - let args = check_args(None, None); + let args = check_args(vec![], None); let result = route_check(&args, "".as_bytes()); match result { Err(e) => assert!( @@ -338,7 +347,7 @@ mod tests { #[rstest] fn route_check_only_empty_lines_returns_error() { - let args = check_args(None, None); + let args = check_args(vec![], None); let result = route_check(&args, "\n\n \n".as_bytes()); match result { Err(e) => assert!( @@ -349,11 +358,11 @@ mod tests { } } - // === route_check: --command flag takes precedence === + // === route_check: positional command takes precedence === #[rstest] fn route_check_command_flag_takes_precedence_over_stdin() { - let args = check_args(Some("echo hello"), Some("claude-code-hook")); + let args = check_args(vec!["echo", "hello"], Some("claude-code-hook")); let route = route_check(&args, std::io::empty()); let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}"))); assert_eq!( @@ -364,11 +373,11 @@ mod tests { ); } - // === route_check: --format flag === + // === route_check: --input-format flag === #[rstest] fn route_check_explicit_format_claude_code_hook() { - let args = check_args(None, Some("claude-code-hook")); + let args = check_args(vec![], Some("claude-code-hook")); let stdin_json = indoc! {r#" { "tool_name": "Bash", @@ -393,7 +402,7 @@ mod tests { #[rstest] fn route_check_unknown_format_returns_error() { - let args = check_args(None, Some("invalid-format")); + let args = check_args(vec![], Some("invalid-format")); let result = route_check(&args, r#"{"command": "ls"}"#.as_bytes()); match result { Err(e) => assert!( diff --git a/src/main.rs b/src/main.rs index 45c08dd..377a3f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,10 +140,10 @@ mod tests { #[rstest] fn run_command_check_with_command_returns_zero() { let cmd = Commands::Check(CheckArgs { - command: Some("echo hello".into()), input_format: None, output_format: cli::OutputFormat::Text, verbose: false, + command: vec!["echo".into(), "hello".into()], }); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let exit_code = run_command(cmd, &cwd, std::io::empty()); @@ -153,10 +153,10 @@ mod tests { #[rstest] fn run_command_check_with_empty_stdin_returns_two() { let cmd = Commands::Check(CheckArgs { - command: None, input_format: None, output_format: cli::OutputFormat::Text, verbose: false, + command: vec![], }); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let exit_code = run_command(cmd, &cwd, "".as_bytes()); @@ -166,10 +166,10 @@ mod tests { #[rstest] fn run_command_check_with_stdin_json_returns_zero() { let cmd = Commands::Check(CheckArgs { - command: None, input_format: None, output_format: cli::OutputFormat::Text, verbose: false, + command: vec![], }); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let exit_code = run_command(cmd, &cwd, r#"{"command": "ls"}"#.as_bytes()); @@ -179,10 +179,10 @@ mod tests { #[rstest] fn run_command_check_with_plaintext_stdin_returns_zero() { let cmd = Commands::Check(CheckArgs { - command: None, input_format: None, output_format: cli::OutputFormat::Text, verbose: false, + command: vec![], }); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let exit_code = run_command(cmd, &cwd, "echo hello\n".as_bytes()); @@ -212,10 +212,10 @@ mod tests { #[rstest] fn run_command_check_with_multiline_plaintext_stdin_returns_zero() { let cmd = Commands::Check(CheckArgs { - command: None, input_format: None, output_format: cli::OutputFormat::Text, verbose: false, + command: vec![], }); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let input = indoc! {" diff --git a/tests/e2e/check_generic.rs b/tests/e2e/check_generic.rs index daf758c..8812b74 100644 --- a/tests/e2e/check_generic.rs +++ b/tests/e2e/check_generic.rs @@ -16,19 +16,20 @@ fn check_env() -> TestEnv { // --- CLI argument mode (JSON output) --- #[rstest] -#[case::deny_rm("rm -rf /", 0, "deny")] -#[case::allow_git_status("git status", 0, "allow")] -#[case::comment_before_command("# description\ngit status", 0, "allow")] -#[case::comment_only("# just a comment", 0, "ask")] +#[case::deny_rm(&["rm", "-rf", "/"], 0, "deny")] +#[case::allow_git_status(&["git", "status"], 0, "allow")] fn check_command_arg_json( check_env: TestEnv, - #[case] command: &str, + #[case] command: &[&str], #[case] expected_exit: i32, #[case] expected_decision: &str, ) { let assert = check_env .command() - .args(["check", "--output-format", "json", "--command", command]) + .arg("check") + .args(["--output-format", "json"]) + .arg("--") + .args(command) .assert(); let output = assert.code(expected_exit).get_output().stdout.clone(); let json: serde_json::Value = @@ -39,17 +40,19 @@ fn check_command_arg_json( // --- CLI argument mode (text output, default) --- #[rstest] -#[case::deny_rm("rm -rf /", "deny")] -#[case::allow_git_status("git status", "allow")] -#[case::comment_only("# just a comment", "ask")] +#[case::deny_rm(&["rm", "-rf", "/"], "deny")] +#[case::allow_git_status(&["git", "status"], "allow")] +#[case::comment_only(&["# just a comment"], "ask")] fn check_command_arg_text( check_env: TestEnv, - #[case] command: &str, + #[case] command: &[&str], #[case] expected_decision: &str, ) { let assert = check_env .command() - .args(["check", "--command", command]) + .arg("check") + .arg("--") + .args(command) .assert(); let output = assert.code(0).get_output().stdout.clone(); let stdout = String::from_utf8_lossy(&output); @@ -59,6 +62,29 @@ fn check_command_arg_text( ); } +// --- Plaintext stdin with comments --- + +#[rstest] +fn check_stdin_comment_before_command(check_env: TestEnv) { + let assert = check_env + .command() + .args(["check", "--output-format", "json"]) + .write_stdin(indoc! {" + # description + git status + "}) + .assert(); + let output = assert.code(0).get_output().stdout.clone(); + let stdout = String::from_utf8(output).unwrap(); + let jsons: Vec = stdout + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(jsons.len(), 2); + assert_eq!(jsons[0]["decision"], "ask"); + assert_eq!(jsons[1]["decision"], "allow"); +} + // --- stdin JSON mode --- #[rstest] @@ -116,7 +142,7 @@ fn check_plaintext_stdin_single_line(check_env: TestEnv) { fn check_deny_includes_reason(check_env: TestEnv) { let assert = check_env .command() - .args(["check", "--output-format", "json", "--command", "rm -rf /"]) + .args(["check", "--output-format", "json", "--", "rm", "-rf", "/"]) .assert(); let output = assert.code(0).get_output().stdout.clone(); let json: serde_json::Value = @@ -147,8 +173,9 @@ fn check_allow_with_sandbox_info() { "check", "--output-format", "json", - "--command", - "python3 script.py", + "--", + "python3", + "script.py", ]) .assert(); let output = assert.code(0).get_output().stdout.clone(); diff --git a/tests/e2e/error_handling.rs b/tests/e2e/error_handling.rs index 04ba68f..952bc81 100644 --- a/tests/e2e/error_handling.rs +++ b/tests/e2e/error_handling.rs @@ -6,7 +6,7 @@ use super::helpers::TestEnv; // --- Invalid config: syntax error --- #[rstest] -#[case::check(&["check", "--command", "git status"], 2)] +#[case::check(&["check", "--", "git", "status"], 2)] #[case::exec(&["exec", "--dry-run", "--", "echo", "hello"], 1)] fn invalid_config_exits_with_error(#[case] args: &[&str], #[case] expected_exit: i32) { let env = TestEnv::new("rules: [invalid yaml\n broken:"); @@ -19,7 +19,7 @@ fn invalid_config_exits_with_error(#[case] args: &[&str], #[case] expected_exit: // --- Invalid config: validation error (deny + sandbox) --- #[rstest] -#[case::check(&["check", "--command", "rm -rf /"], 2)] +#[case::check(&["check", "--", "rm", "-rf", "/"], 2)] #[case::exec(&["exec", "--dry-run", "--", "rm", "-rf", "/"], 1)] fn validation_error_deny_with_sandbox(#[case] args: &[&str], #[case] expected_exit: i32) { let env = TestEnv::new(indoc! {" @@ -66,7 +66,7 @@ fn exec_nonexistent_command() { "}, )] #[case::check_deny( - &["check", "--command", "rm -rf /"], + &["check", "--", "rm", "-rf", "/"], 0, indoc! {" rules: @@ -86,13 +86,7 @@ fn no_config_check_returns_default() { let env = TestEnv::new("{}"); let assert = env .command() - .args([ - "check", - "--output-format", - "json", - "--command", - "git status", - ]) + .args(["check", "--output-format", "json", "--", "git", "status"]) .assert(); let output = assert.code(0).get_output().stdout.clone(); let json: serde_json::Value =