Compare commits

...

2 Commits

Author SHA1 Message Date
Ahmed Ibrahim
e58e4f939a Stabilize Windows parser timeout test
The timeout regression test was using a .cmd wrapper, which can leave a child process running after the wrapper is killed on Windows. Replace it with a direct long-lived child process and factor the wait path into a helper so the test matches the real timeout behavior more closely.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-03-19 22:26:50 +00:00
Ahmed Ibrahim
61dbfabc13 Bound Windows PowerShell parser hangs
Fail closed when the PowerShell AST helper stalls on startup, preserve a 5s parser cap, add a stuck-child regression test, and close stdin so safe wrapper invocations do not time out waiting on inherited input.

Co-authored-by: Codex <noreply@openai.com>
2026-03-17 17:09:07 +00:00

View File

@@ -1,11 +1,20 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use serde::Deserialize;
use std::io::Read;
use std::path::Path;
use std::process::Child;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::sync::LazyLock;
use std::thread;
use std::time::Duration;
use std::time::Instant;
const POWERSHELL_PARSER_SCRIPT: &str = include_str!("powershell_parser.ps1");
const POWERSHELL_PARSER_TIMEOUT: Duration = Duration::from_secs(5);
const POWERSHELL_PARSER_POLL_INTERVAL: Duration = Duration::from_millis(10);
/// On Windows, we conservatively allow only clearly read-only PowerShell invocations
/// that match a small safelist. Anything else (including direct CMD commands) is unsafe.
@@ -127,7 +136,7 @@ fn is_powershell_executable(exe: &str) -> bool {
fn parse_with_powershell_ast(executable: &str, script: &str) -> PowershellParseOutcome {
let encoded_script = encode_powershell_base64(script);
let encoded_parser_script = encoded_parser_script();
match Command::new(executable)
let mut child = match Command::new(executable)
.args([
"-NoLogo",
"-NoProfile",
@@ -136,18 +145,74 @@ fn parse_with_powershell_ast(executable: &str, script: &str) -> PowershellParseO
encoded_parser_script,
])
.env("CODEX_POWERSHELL_PAYLOAD", &encoded_script)
.output()
// Match `Command::output()` so PowerShell does not stay alive waiting on inherited stdin
// after the parser script has already finished.
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(output) if output.status.success() => {
if let Ok(result) =
serde_json::from_slice::<PowershellParserOutput>(output.stdout.as_slice())
{
Ok(child) => child,
Err(_) => return PowershellParseOutcome::Failed,
};
let Some(output) = wait_for_output_with_timeout(&mut child, POWERSHELL_PARSER_TIMEOUT) else {
return PowershellParseOutcome::Failed;
};
match output.status.success() {
true => {
if let Ok(result) = serde_json::from_slice::<PowershellParserOutput>(&output.stdout) {
result.into_outcome()
} else {
PowershellParseOutcome::Failed
}
}
_ => PowershellParseOutcome::Failed,
false => PowershellParseOutcome::Failed,
}
}
fn wait_for_output_with_timeout(child: &mut Child, timeout: Duration) -> Option<Output> {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(mut reader) = child.stdout.take()
&& reader.read_to_end(&mut stdout).is_err()
{
return None;
}
if let Some(mut reader) = child.stderr.take()
&& reader.read_to_end(&mut stderr).is_err()
{
return None;
}
return Some(Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
thread::sleep(POWERSHELL_PARSER_POLL_INTERVAL);
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
}
@@ -349,6 +414,8 @@ mod tests {
use super::*;
use crate::powershell::try_find_pwsh_executable_blocking;
use std::string::ToString;
use std::time::Duration;
use std::time::Instant;
/// Converts a slice of string literals into owned `String`s for the tests.
fn vec_str(args: &[&str]) -> Vec<String> {
@@ -620,4 +687,20 @@ mod tests {
);
}
}
#[test]
fn powershell_ast_parser_times_out_for_stuck_child() {
let mut child = Command::new("timeout.exe")
.args(["/t", "10", "/nobreak"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn timeout");
let started = Instant::now();
let output = wait_for_output_with_timeout(&mut child, POWERSHELL_PARSER_TIMEOUT);
assert!(output.is_none());
assert!(started.elapsed() < POWERSHELL_PARSER_TIMEOUT + Duration::from_secs(1));
}
}