feat(shell): refuse NUL bytes in command/cwd before spawn (#5529 mode 3)

A NUL byte cannot cross the exec boundary: Command panics on it,
killing the worker instead of failing the call. require_no_nul rejects
command and cwd/working_dir with the byte offset and a re-send hint.
Background-job observe/resume half of mode 3 already exists via the
tasks tool (list/read/cancel).
This commit is contained in:
CodeWhale Bot committed 2026-09-17 21:51:36 -07:00
1 parent 6533d44df9
commit b0866943b4
2 files changed
+61 -1

No files matched your search

+13 -1
View File
@@ -3872,6 +3872,17 @@ fn attach_shell_owner_metadata(metadata: &mut serde_json::Value, context: &ToolC
metadata["owner_agent_name"] = json!(owner.agent_name);
}
/// NUL bytes cannot cross the `exec` boundary: `Command` panics on them.
/// Refuse with the byte offset before anything spawns (#5529).
fn require_no_nul<'a>(value: &'a str, field: &str) -> Result<&'a str, ToolError> {
if let Some(offset) = value.find('\0') {
return Err(ToolError::invalid_input(format!(
"Shell {field} contains a NUL byte at byte offset {offset}; it cannot cross the exec boundary. Remove it (usually a truncated heredoc or binary paste) and re-send."
)));
}
Ok(value)
}
fn enforce_readonly_github_network_policy(
command: &str,
context: &ToolContext,
@@ -5109,7 +5120,7 @@ impl ToolSpec for BashTool {
)));
}
}
let command = required_str(&input, "command")?;
let command = require_no_nul(required_str(&input, "command")?, "command")?;
match context.shell_policy {
ShellPolicy::None => {
return Ok(ToolResult::error(
@@ -5278,6 +5289,7 @@ impl ToolSpec for BashTool {
value
.as_str()
.ok_or_else(|| type_mismatch(name, value, "a string"))
.and_then(|dir| require_no_nul(dir, name))
})
.transpose()?
{
+48
View File
@@ -3885,6 +3885,54 @@ async fn unknown_bash_action_is_refused_instead_of_running_the_command() {
assert!(!marker.exists(), "the command must not have run");
}
/// A NUL byte cannot cross the `exec` boundary: `Command` panics on it.
/// Refuse with the byte offset before anything spawns (#5529).
#[tokio::test]
async fn nul_byte_in_shell_command_is_refused_before_spawn() {
let workspace = tempdir().expect("workspace");
let context = ToolContext::new(workspace.path().to_path_buf());
let marker = workspace.path().join("should-not-exist");
let error = BashTool::new("Bash")
.execute(
json!({
"command": format!("echo hi\0; touch {}", marker.display()),
}),
&context,
)
.await
.expect_err("NUL byte must be refused");
let message = error.to_string();
assert!(message.contains("NUL byte"), "{message}");
assert!(message.contains("byte offset 7"), "{message}");
assert!(!marker.exists(), "the command must not have run");
}
/// `cwd` crosses the same boundary via `current_dir`, so the guard covers it
/// too (#5529).
#[tokio::test]
async fn nul_byte_in_shell_cwd_is_refused_before_spawn() {
let workspace = tempdir().expect("workspace");
let context = ToolContext::new(workspace.path().to_path_buf());
let error = BashTool::new("Bash")
.execute(
json!({
"command": "echo hi",
"cwd": "sub\0dir",
}),
&context,
)
.await
.expect_err("NUL byte must be refused");
let message = error.to_string();
assert!(message.contains("NUL byte"), "{message}");
assert!(message.contains("cwd"), "{message}");
assert!(message.contains("byte offset 3"), "{message}");
}
/// The same hole one type down. `and_then(as_str).unwrap_or("run")` read a
/// non-string `action` as absent and fell through to the branch that executes
/// arbitrary code, so `Bash{action: 3, command: "…"}` ran the command. `File`,