@@ -1438,40 +1438,67 @@ async fn tool_shell_exec(
14381438 let policy_timeout = exec_policy. map ( |p| p. timeout_secs ) . unwrap_or ( 30 ) ;
14391439 let timeout_secs = input[ "timeout_seconds" ] . as_u64 ( ) . unwrap_or ( policy_timeout) ;
14401440
1441- // Shell resolution: prefer sh (Git Bash/MSYS2) on Windows to avoid cmd.exe
1442- // quoting issues (% expansion mangles yt-dlp templates, " in filenames
1443- // converted to # by --restrict-filenames). Fall back to cmd if sh not found.
1444- #[ cfg( windows) ]
1445- let git_sh: Option < & str > = {
1446- const SH_PATHS : & [ & str ] = & [
1447- "C:\\ Program Files\\ Git\\ usr\\ bin\\ sh.exe" ,
1448- "C:\\ Program Files (x86)\\ Git\\ usr\\ bin\\ sh.exe" ,
1449- ] ;
1450- SH_PATHS
1451- . iter ( )
1452- . copied ( )
1453- . find ( |p| std:: path:: Path :: new ( p) . exists ( ) )
1454- } ;
1455- let ( shell, shell_arg) = if cfg ! ( windows) {
1456- #[ cfg( windows) ]
1457- {
1458- if let Some ( sh) = git_sh {
1459- ( sh, "-c" )
1460- } else {
1461- ( "cmd" , "/C" )
1462- }
1441+ // SECURITY: Determine execution strategy based on exec policy.
1442+ //
1443+ // In Allowlist mode (default): Use direct execution via shlex argv splitting.
1444+ // This avoids invoking a shell interpreter, which eliminates an entire class
1445+ // of injection attacks (encoding tricks, $IFS, glob expansion, etc.).
1446+ //
1447+ // In Full mode: User explicitly opted into unrestricted shell access,
1448+ // so we use sh -c / cmd /C as before.
1449+ let use_direct_exec = exec_policy
1450+ . map ( |p| p. mode == openfang_types:: config:: ExecSecurityMode :: Allowlist )
1451+ . unwrap_or ( true ) ; // Default to safe mode
1452+
1453+ let mut cmd = if use_direct_exec {
1454+ // SAFE PATH: Split command into argv using POSIX shell lexer rules,
1455+ // then execute the binary directly — no shell interpreter involved.
1456+ let argv = shlex:: split ( command) . ok_or_else ( || {
1457+ "Command contains unmatched quotes or invalid shell syntax" . to_string ( )
1458+ } ) ?;
1459+ if argv. is_empty ( ) {
1460+ return Err ( "Empty command after parsing" . to_string ( ) ) ;
14631461 }
1464- # [ cfg ( not ( windows ) ) ]
1465- {
1466- ( "sh" , "-c" )
1462+ let mut c = tokio :: process :: Command :: new ( & argv [ 0 ] ) ;
1463+ if argv . len ( ) > 1 {
1464+ c . args ( & argv [ 1 .. ] ) ;
14671465 }
1466+ c
14681467 } else {
1469- ( "sh" , "-c" )
1468+ // UNSAFE PATH: Full mode — user explicitly opted in to shell interpretation.
1469+ // Shell resolution: prefer sh (Git Bash/MSYS2) on Windows.
1470+ #[ cfg( windows) ]
1471+ let git_sh: Option < & str > = {
1472+ const SH_PATHS : & [ & str ] = & [
1473+ "C:\\ Program Files\\ Git\\ usr\\ bin\\ sh.exe" ,
1474+ "C:\\ Program Files (x86)\\ Git\\ usr\\ bin\\ sh.exe" ,
1475+ ] ;
1476+ SH_PATHS
1477+ . iter ( )
1478+ . copied ( )
1479+ . find ( |p| std:: path:: Path :: new ( p) . exists ( ) )
1480+ } ;
1481+ let ( shell, shell_arg) = if cfg ! ( windows) {
1482+ #[ cfg( windows) ]
1483+ {
1484+ if let Some ( sh) = git_sh {
1485+ ( sh, "-c" )
1486+ } else {
1487+ ( "cmd" , "/C" )
1488+ }
1489+ }
1490+ #[ cfg( not( windows) ) ]
1491+ {
1492+ ( "sh" , "-c" )
1493+ }
1494+ } else {
1495+ ( "sh" , "-c" )
1496+ } ;
1497+ let mut c = tokio:: process:: Command :: new ( shell) ;
1498+ c. arg ( shell_arg) . arg ( command) ;
1499+ c
14701500 } ;
14711501
1472- let mut cmd = tokio:: process:: Command :: new ( shell) ;
1473- cmd. arg ( shell_arg) . arg ( command) ;
1474-
14751502 // Set working directory to agent workspace so files are created there
14761503 if let Some ( ws) = workspace_root {
14771504 cmd. current_dir ( ws) ;
0 commit comments