Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 50 additions & 13 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,32 @@ fn decode_file_bytes_for_path(_path: &Path, bytes: &[u8]) -> String {
/// Check if a path refers to /dev/null after normalization.
/// Handles attempts to bypass via paths like `/dev/../dev/null`.
fn is_dev_null(path: &Path) -> bool {
normalize_dev_path(path) == Path::new(DEV_NULL)
}

/// File descriptor aliased by `/dev/stdin`, `/dev/stdout`, `/dev/stderr` or
/// `/dev/fd/N` (after `..` normalization), as on Linux.
///
/// Decision: these paths are resolved at the interpreter level like
/// `/dev/null`, never through the VFS. Writing them as regular files silently
/// swallowed `echo err > /dev/stderr`, a pattern agents use constantly.
fn dev_fd_alias(path: &Path) -> Option<i32> {
let normalized = normalize_dev_path(path);
match normalized.to_str()? {
"/dev/stdin" => Some(0),
"/dev/stdout" => Some(1),
"/dev/stderr" => Some(2),
other => {
let n = other.strip_prefix("/dev/fd/")?;
if n.is_empty() || n.len() > 9 || !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
n.parse().ok()
}
}
}

fn normalize_dev_path(path: &Path) -> PathBuf {
// Normalize the path to handle .. and . components
let mut normalized = PathBuf::new();
for component in path.components() {
Expand All @@ -659,7 +685,7 @@ fn is_dev_null(path: &Path) -> bool {
if normalized.as_os_str().is_empty() {
normalized.push("/");
}
normalized == Path::new(DEV_NULL)
normalized
}

/// THREAT[TM-INJ-009,TM-INJ-016]: Check if a variable name is an internal marker.
Expand Down Expand Up @@ -6157,7 +6183,10 @@ impl Interpreter {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
self.ensure_persistent_fd_capacity(fd)?;
if is_dev_null(&path) {
if let Some(target_fd) = dev_fd_alias(&path) {
let entry = self.exec_fd_alias_target(target_fd);
self.exec_fd_table.insert(fd, entry);
} else if is_dev_null(&path) {
self.exec_fd_table.insert(fd, FdTarget::DevNull);
} else {
// Truncate file on open (like real exec >file)
Expand All @@ -6171,7 +6200,10 @@ impl Interpreter {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
self.ensure_persistent_fd_capacity(fd)?;
if is_dev_null(&path) {
if let Some(target_fd) = dev_fd_alias(&path) {
let entry = self.exec_fd_alias_target(target_fd);
self.exec_fd_table.insert(fd, entry);
} else if is_dev_null(&path) {
self.exec_fd_table.insert(fd, FdTarget::DevNull);
} else {
self.exec_fd_table
Expand All @@ -6186,16 +6218,7 @@ impl Interpreter {
self.exec_fd_table.remove(&fd);
} else if let Ok(target_fd) = target.parse::<i32>() {
// exec N>&M duplicates fd M to fd N
let target_entry = if target_fd == 1 {
FdTarget::Stdout
} else if target_fd == 2 {
FdTarget::Stderr
} else {
self.exec_fd_table
.get(&target_fd)
.cloned()
.unwrap_or(FdTarget::Stdout)
};
let target_entry = self.exec_fd_alias_target(target_fd);
self.ensure_persistent_fd_capacity(fd)?;
self.exec_fd_table.insert(fd, target_entry);
}
Expand All @@ -6207,6 +6230,20 @@ impl Interpreter {
self.apply_redirections(result, redirects).await
}

/// Target for `exec N>&M` / `exec N>/dev/fd/M`.
fn exec_fd_alias_target(&self, target_fd: i32) -> FdTarget {
if target_fd == 1 {
FdTarget::Stdout
} else if target_fd == 2 {
FdTarget::Stderr
} else {
self.exec_fd_table
.get(&target_fd)
.cloned()
.unwrap_or(FdTarget::Stdout)
}
}

fn ensure_persistent_fd_capacity(&self, fd: i32) -> Result<()> {
if fd < 0 {
return Err(crate::error::Error::Execution(format!(
Expand Down
198 changes: 126 additions & 72 deletions crates/bashkit/src/interpreter/redirection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ impl Interpreter {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
// Handle /dev/null at interpreter level - cannot be bypassed
if is_dev_null(&path) {
if dev_fd_alias(&path) == Some(0) {
// `< /dev/stdin` re-reads the current stdin: no-op.
} else if is_dev_null(&path) {
stdin = Some(crate::StreamData::new()); // EOF
} else if self.shell_profile.is_logic_only() {
return Err(crate::error::Error::Execution(format!(
Expand Down Expand Up @@ -134,7 +136,10 @@ impl Interpreter {
RedirectKind::Output | RedirectKind::Clobber => {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
if is_dev_null(&path) {
if let Some(target_fd) = dev_fd_alias(&path) {
self.dup_output_fast(&mut result, redirect.fd.unwrap_or(1), target_fd)
.await?;
} else if is_dev_null(&path) {
match redirect.fd {
Some(2) => result.stderr = crate::StreamData::new(),
_ => result.stdout = crate::StreamData::new(),
Expand Down Expand Up @@ -183,7 +188,10 @@ impl Interpreter {
RedirectKind::Append => {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
if is_dev_null(&path) {
if let Some(target_fd) = dev_fd_alias(&path) {
self.dup_output_fast(&mut result, redirect.fd.unwrap_or(1), target_fd)
.await?;
} else if is_dev_null(&path) {
match redirect.fd {
Some(2) => result.stderr = crate::StreamData::new(),
_ => result.stdout = crate::StreamData::new(),
Expand Down Expand Up @@ -221,7 +229,18 @@ impl Interpreter {
RedirectKind::OutputBoth => {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
if is_dev_null(&path) {
if let Some(target_fd) = dev_fd_alias(&path) {
// `&> /dev/fd/N` == `>&N 2>&N`, resolved against the
// original descriptors.
if target_fd == 2 {
let mut combined = std::mem::take(&mut result.stdout);
combined.append(&result.stderr);
result.stderr = combined;
} else {
self.dup_output_fast(&mut result, 1, target_fd).await?;
self.dup_output_fast(&mut result, 2, target_fd).await?;
}
} else if is_dev_null(&path) {
result.stdout = crate::StreamData::new();
result.stderr = crate::StreamData::new();
} else {
Expand All @@ -241,48 +260,7 @@ impl Interpreter {
let target = self.expand_word(&redirect.target).await?;
let target_fd: i32 = target.parse().unwrap_or(1);
let src_fd = redirect.fd.unwrap_or(1);

// Check exec_fd_table for persistent fd targets
if let Some(fd_target) = self.exec_fd_table.get(&target_fd).cloned() {
let data = if src_fd == 2 {
std::mem::take(&mut result.stderr)
} else {
std::mem::take(&mut result.stdout)
};
match &fd_target {
FdTarget::Stdout => result.stdout.append(&data),
FdTarget::Stderr => result.stderr.append(&data),
FdTarget::DevNull => {}
FdTarget::WriteFile(path, _) | FdTarget::AppendFile(path, _) => {
self.fs.append_file(path, data.as_bytes()).await?;
}
}
} else {
match (src_fd, target_fd) {
(2, 1) => {
result.stdout.append(&result.stderr);
result.stderr = crate::StreamData::new();
}
(1, 2) => {
result.stderr.append(&result.stdout);
result.stdout = crate::StreamData::new();
}
(src, dst) if dst >= 3 => {
let data = if src == 2 {
std::mem::take(&mut result.stderr)
} else {
std::mem::take(&mut result.stdout)
};
if self.pending_fd_capture_depth > 0 {
// Move content to pending_fd_output for compound
// redirect routing (e.g. `echo msg 1>&3` inside
// `{ ... } 3>&1 >file`).
self.append_pending_fd_output(dst, &data);
}
}
_ => {}
}
}
self.dup_output_fast(&mut result, src_fd, target_fd).await?;
}
RedirectKind::Input
| RedirectKind::HereString
Expand All @@ -295,6 +273,57 @@ impl Interpreter {
Ok(result)
}

/// `src_fd>&target_fd` on the fast (single-pass) redirect path.
async fn dup_output_fast(
&mut self,
result: &mut ExecResult,
src_fd: i32,
target_fd: i32,
) -> Result<()> {
// Check exec_fd_table for persistent fd targets
if let Some(fd_target) = self.exec_fd_table.get(&target_fd).cloned() {
let data = if src_fd == 2 {
std::mem::take(&mut result.stderr)
} else {
std::mem::take(&mut result.stdout)
};
match &fd_target {
FdTarget::Stdout => result.stdout.append(&data),
FdTarget::Stderr => result.stderr.append(&data),
FdTarget::DevNull => {}
FdTarget::WriteFile(path, _) | FdTarget::AppendFile(path, _) => {
self.fs.append_file(path, data.as_bytes()).await?;
}
}
} else {
match (src_fd, target_fd) {
(2, 1) => {
result.stdout.append(&result.stderr);
result.stderr = crate::StreamData::new();
}
(1, 2) => {
result.stderr.append(&result.stdout);
result.stdout = crate::StreamData::new();
}
(src, dst) if dst >= 3 => {
let data = if src == 2 {
std::mem::take(&mut result.stderr)
} else {
std::mem::take(&mut result.stdout)
};
if self.pending_fd_capture_depth > 0 {
// Move content to pending_fd_output for compound
// redirect routing (e.g. `echo msg 1>&3` inside
// `{ ... } 3>&1 >file`).
self.append_pending_fd_output(dst, &data);
}
}
_ => {}
}
}
Ok(())
}

pub(super) fn logic_only_redirect_error(&self, redirects: &[Redirect]) -> Option<String> {
if !self.shell_profile.is_logic_only() {
return None;
Expand Down Expand Up @@ -342,6 +371,11 @@ impl Interpreter {
RedirectKind::Output | RedirectKind::Clobber => {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
if let Some(target_fd) = dev_fd_alias(&path) {
let src_fd = redirect.fd.unwrap_or(1);
self.dup_output_fd_table(src_fd, target_fd, &mut fd1, &mut fd2);
continue;
}

if redirect.kind == RedirectKind::Output
&& self.scoped.variables.get("SHOPT_C").map(|v| v.as_str()) == Some("1")
Expand Down Expand Up @@ -370,6 +404,11 @@ impl Interpreter {
RedirectKind::Append => {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
if let Some(target_fd) = dev_fd_alias(&path) {
let src_fd = redirect.fd.unwrap_or(1);
self.dup_output_fd_table(src_fd, target_fd, &mut fd1, &mut fd2);
continue;
}
let target = if is_dev_null(&path) {
FdTarget::DevNull
} else {
Expand All @@ -383,6 +422,11 @@ impl Interpreter {
RedirectKind::OutputBoth => {
let target_path = self.expand_word(&redirect.target).await?;
let path = self.resolve_path(&target_path);
if let Some(target_fd) = dev_fd_alias(&path) {
self.dup_output_fd_table(1, target_fd, &mut fd1, &mut fd2);
self.dup_output_fd_table(2, 1, &mut fd1, &mut fd2);
continue;
}
let target = if is_dev_null(&path) {
FdTarget::DevNull
} else {
Expand All @@ -395,32 +439,7 @@ impl Interpreter {
let target = self.expand_word(&redirect.target).await?;
let target_fd: i32 = target.parse().unwrap_or(1);
let src_fd = redirect.fd.unwrap_or(1);

// Look up exec_fd_table for persistent fd targets
if let Some(exec_target) = self.exec_fd_table.get(&target_fd).cloned() {
match src_fd {
2 => fd2 = exec_target,
_ => fd1 = exec_target,
}
} else {
// Resolve target from current fd table state
let resolved = match target_fd {
1 => Some(fd1.clone()),
2 => Some(fd2.clone()),
_ => None,
};
if let Some(target) = resolved {
match src_fd {
1 => fd1 = target,
2 => fd2 = target,
n if n >= 3 => {
// Store fd3+ target for routing pending_fd_output later
self.pending_fd_targets.push((n, target));
}
_ => {}
}
}
}
self.dup_output_fd_table(src_fd, target_fd, &mut fd1, &mut fd2);
}
RedirectKind::Input
| RedirectKind::HereString
Expand Down Expand Up @@ -463,4 +482,39 @@ impl Interpreter {
result.stderr = new_stderr;
Ok(result)
}

/// `src_fd>&target_fd` on the fd-table redirect path.
fn dup_output_fd_table(
&mut self,
src_fd: i32,
target_fd: i32,
fd1: &mut FdTarget,
fd2: &mut FdTarget,
) {
// Look up exec_fd_table for persistent fd targets
if let Some(exec_target) = self.exec_fd_table.get(&target_fd).cloned() {
match src_fd {
2 => *fd2 = exec_target,
_ => *fd1 = exec_target,
}
} else {
// Resolve target from current fd table state
let resolved = match target_fd {
1 => Some(fd1.clone()),
2 => Some(fd2.clone()),
_ => None,
};
if let Some(target) = resolved {
match src_fd {
1 => *fd1 = target,
2 => *fd2 = target,
n if n >= 3 => {
// Store fd3+ target for routing pending_fd_output later
self.pending_fd_targets.push((n, target));
}
_ => {}
}
}
}
}
}
Loading
Loading