Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions brush-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,10 @@ uuid = { version = "1.23.1", features = ["js"] }

[dev-dependencies]
anyhow = "1.0.102"
# Used only by integration tests that need a realistically-populated builtin
# table (e.g. `echo`, `.`/`source`) to exercise the capability-interceptor
# hooks. This is a dev-dependency cycle (brush-builtins depends on brush-core),
# which Cargo permits for dev-dependencies.
brush-builtins = { version = "^0.2.0", path = "../brush-builtins" }
pretty_assertions = { version = "1.4.1", features = ["unstable"] }
tempfile = "3.27.0"
17 changes: 17 additions & 0 deletions brush-core/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,23 @@ pub(crate) fn execute_external_command(
})
.collect::<Vec<_>>();

// Give the configured command interceptor (capability confinement) a chance
// to deny this external spawn. This site is the single funnel for *all*
// external commands, including the path-separator branch (e.g. `/bin/rm`,
// `./x`) that bypasses PATH and the builtin table, so a policy here cannot
// be circumvented by spelling the command differently.
{
use crate::extensions::CommandInterceptor as _;
let hook_args: Vec<String> = cmd_args.iter().map(|s| (*s).clone()).collect();
if let extensions::ExecDecision::Deny(reason) = context
.shell
.command_interceptor()
.before_exec(executable_path, hook_args.as_slice())
{
return Err(error::ErrorKind::ExecDenied(context.command_name.clone(), reason).into());
}
}

// Before we lose ownership of the open files, figure out if stdin will be a terminal.
let child_stdin_is_terminal = context
.try_fd(openfiles::OpenFiles::STDIN_FD)
Expand Down
7 changes: 7 additions & 0 deletions brush-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,12 @@ pub enum ErrorKind {
/// A glob pattern failed to match any files (failglob).
#[error("no match: {0}")]
NoMatch(String),

/// Execution of an external command was denied by the configured
/// command interceptor (capability confinement). The first field is the
/// program; the second is the reason supplied by the interceptor.
#[error("{0}: execution denied: {1}")]
ExecDenied(String, String),
}

/// Trait implementable by built-in commands to represent errors.
Expand Down Expand Up @@ -363,6 +369,7 @@ impl From<&ErrorKind> for results::ExecutionExitCode {
ErrorKind::FunctionParseError(..) => Self::InvalidUsage,
ErrorKind::TestCommandParseError(..) => Self::InvalidUsage,
ErrorKind::FailedToExecuteCommand(..) => Self::CannotExecute,
ErrorKind::ExecDenied(..) => Self::CannotExecute,
ErrorKind::FunctionNameShadowsSpecialBuiltin { .. } => Self::InvalidUsage,
ErrorKind::IoError(io_err) => io_err.into(),
ErrorKind::BuiltinError(inner, ..) => inner.as_exit_code(),
Expand Down
105 changes: 101 additions & 4 deletions brush-core/src/extensions.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
//! Definition of shell behavior traits and defaults.

use std::path::Path;

use crate::{Shell, error, extensions};

/// Trait for static shell extensions. Collects all associated types needed to
/// instantiate a shell into a single containing struct.
pub trait ShellExtensions: Clone + Default + Send + Sync + 'static {
/// Type of the error behavior implementation.
type ErrorFormatter: ErrorFormatter;

/// Type of the command-interceptor (capability-confinement) implementation.
///
/// This component allows an embedding host to observe — and optionally
/// *deny* — external command execution and file opens as they happen,
/// in-process. See [`CommandInterceptor`] for the available hooks.
type CommandInterceptor: CommandInterceptor;
}

/// Shell extensions implementation constructed from component types.
#[derive(Clone, Default)]
pub struct ShellExtensionsImpl<EF: ErrorFormatter = DefaultErrorFormatter> {
_marker: std::marker::PhantomData<EF>,
pub struct ShellExtensionsImpl<
EF: ErrorFormatter = DefaultErrorFormatter,
CI: CommandInterceptor = DefaultCommandInterceptor,
> {
_marker: std::marker::PhantomData<(EF, CI)>,
}

impl<EF: ErrorFormatter> ShellExtensions for ShellExtensionsImpl<EF> {
impl<EF: ErrorFormatter, CI: CommandInterceptor> ShellExtensions for ShellExtensionsImpl<EF, CI> {
type ErrorFormatter = EF;
type CommandInterceptor = CI;
}

/// Default shell extensions implementation.
/// This is a type alias for the most common shell configuration.
pub type DefaultShellExtensions = ShellExtensionsImpl<DefaultErrorFormatter>;
pub type DefaultShellExtensions =
ShellExtensionsImpl<DefaultErrorFormatter, DefaultCommandInterceptor>;

/// Trait for defining shell error behaviors.
pub trait ErrorFormatter: Clone + Default + Send + Sync + 'static {
Expand All @@ -47,6 +61,89 @@ pub struct DefaultErrorFormatter;

impl ErrorFormatter for DefaultErrorFormatter {}

/// Decision returned by [`CommandInterceptor::before_exec`] to control whether
/// an external command is allowed to spawn.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ExecDecision {
/// Allow the external command to be spawned (the default).
Allow,
/// Deny the external command. The contained string explains why; it is
/// surfaced to the shell as an [`error::Error`] and the command does not
/// run.
Deny(String),
}

/// Decision returned by [`CommandInterceptor::before_open`] to control whether
/// a file may be opened.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OpenDecision {
/// Allow the file to be opened (the default).
Allow,
/// Deny opening the file. The contained string explains why; it is surfaced
/// to the shell as an [`error::Error`] and the file is not opened.
Deny(String),
}

/// Trait for intercepting potentially-sensitive shell operations so an
/// embedding host can apply capability confinement (object-capability style
/// authority attenuation) *in-process*.
///
/// The default implementation ([`DefaultCommandInterceptor`]) allows
/// everything, making it byte-for-byte equivalent to a shell with no
/// interceptor at all. Embedders supply their own implementation via the
/// [`ShellExtensions::CommandInterceptor`] associated type to enforce a policy.
///
/// # Why this exists
///
/// Without these hooks, a hosting process cannot reliably confine command
/// execution in-process: a command whose name contains a path separator (e.g.
/// `/bin/rm` or `./script`) bypasses both the `PATH` search and the builtin
/// table and is executed directly. [`before_exec`](Self::before_exec) is called
/// at *every* external-spawn site — including that path-separator branch — so a
/// policy here cannot be circumvented by spelling the command differently.
pub trait CommandInterceptor: Clone + Default + Send + Sync + 'static {
/// Called immediately before an external command is spawned, at every spawn
/// site (including the path-separator branch that bypasses `PATH` and the
/// builtin table). Returning [`ExecDecision::Deny`] prevents the command
/// from running and fails it with an error.
///
/// # Arguments
///
/// * `program` - The program that is about to be executed. For commands
/// resolved via `PATH` this is the resolved absolute path; for
/// path-separator commands it is the path as written by the user.
/// * `args` - The argument strings that would be passed to the program
/// (not including `argv[0]`).
fn before_exec(&self, program: &str, args: &[String]) -> ExecDecision {
let _ = (program, args);
ExecDecision::Allow
}

/// Called immediately before a file is opened via a filesystem path
/// (redirections and `source`/`.`). Returning [`OpenDecision::Deny`]
/// prevents the file from being opened and fails the operation with an
/// error.
///
/// # Arguments
///
/// * `path` - The absolute path that is about to be opened.
/// * `write` - Whether the open requests write access (`true`) or is
/// read-only (`false`).
fn before_open(&self, path: &Path, write: bool) -> OpenDecision {
let _ = (path, write);
OpenDecision::Allow
}
}

/// Default command-interceptor implementation: allows all execs and opens.
///
/// A shell configured with this interceptor behaves identically to a shell with
/// no interception at all.
#[derive(Clone, Default)]
pub struct DefaultCommandInterceptor;

impl CommandInterceptor for DefaultCommandInterceptor {}

/// Trait for placeholder behavior (stub for future extension).
pub trait PlaceholderBehavior: Clone + Default + Send + Sync + 'static {}

Expand Down
22 changes: 22 additions & 0 deletions brush-core/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ pub struct Shell<SE: extensions::ShellExtensions = extensions::DefaultShellExten
#[cfg_attr(feature = "serde", serde(skip, default = "default_error_formatter"))]
error_formatter: SE::ErrorFormatter,

/// Injected command interceptor (capability confinement) behavior.
#[cfg_attr(
feature = "serde",
serde(skip, default = "default_command_interceptor")
)]
command_interceptor: SE::CommandInterceptor,

/// Trap handler configuration for the shell.
traps: crate::traps::TrapHandlerConfig,

Expand Down Expand Up @@ -149,6 +156,7 @@ impl<SE: extensions::ShellExtensions> Clone for Shell<SE> {
fn clone(&self) -> Self {
Self {
error_formatter: self.error_formatter.clone(),
command_interceptor: self.command_interceptor.clone(),
traps: self.traps.clone(),
open_files: self.open_files.clone(),
working_dir: self.working_dir.clone(),
Expand Down Expand Up @@ -212,6 +220,7 @@ impl<SE: extensions::ShellExtensions> Shell<SE> {
// Instantiate the shell with some defaults.
let mut shell = Self {
error_formatter: options.error_formatter,
command_interceptor: options.command_interceptor,
open_files: openfiles::OpenFiles::new(),
options: runtime_options,
name: options.shell_name,
Expand Down Expand Up @@ -349,6 +358,14 @@ impl<SE: extensions::ShellExtensions> Shell<SE> {
pub(crate) const fn last_exit_status_change_count(&self) -> usize {
self.last_exit_status_change_count
}

/// Returns a reference to the shell's configured command interceptor
/// (capability-confinement) behavior. Hosts rarely need to call this
/// directly; the shell consults it automatically before spawning external
/// commands and opening files.
pub const fn command_interceptor(&self) -> &SE::CommandInterceptor {
&self.command_interceptor
}
}

#[inherent::inherent]
Expand Down Expand Up @@ -548,3 +565,8 @@ impl<SE: extensions::ShellExtensions> ShellState for Shell<SE> {
fn default_error_formatter<EF: extensions::ErrorFormatter>() -> EF {
EF::default()
}

#[cfg(feature = "serde")]
fn default_command_interceptor<CI: extensions::CommandInterceptor>() -> CI {
CI::default()
}
4 changes: 4 additions & 0 deletions brush-core/src/shell/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ pub struct CreateOptions<SE: extensions::ShellExtensions = extensions::DefaultSh
/// Error behavior implementation.
#[builder(default)]
pub error_formatter: SE::ErrorFormatter,
/// Command interceptor (capability-confinement) implementation.
#[builder(default)]
pub command_interceptor: SE::CommandInterceptor,
/// Disallow overwriting regular files via output redirection.
#[builder(default)]
pub disallow_overwriting_regular_files_via_output_redirection: bool,
Expand Down Expand Up @@ -233,6 +236,7 @@ impl<SE: extensions::ShellExtensions> Default for Shell<SE> {
fn default() -> Self {
Self {
error_formatter: SE::ErrorFormatter::default(),
command_interceptor: SE::CommandInterceptor::default(),
traps: traps::TrapHandlerConfig::default(),
open_files: openfiles::OpenFiles::default(),
working_dir: PathBuf::default(),
Expand Down
52 changes: 52 additions & 0 deletions brush-core/src/shell/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,23 @@ impl<SE: crate::extensions::ShellExtensions> crate::Shell<SE> {

let path_to_open = self.absolute_path(path.as_ref());

// Consult the configured command interceptor (capability confinement)
// before opening. This is the single choke point through which all
// filesystem-path opens flow (redirections and `source`/`.`), so a
// policy applied here covers every path-based open in the shell.
{
use crate::extensions::CommandInterceptor as _;
let write = open_options_request_write(options);
if let crate::extensions::OpenDecision::Deny(reason) =
self.command_interceptor().before_open(&path_to_open, write)
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("open denied: {reason}"),
));
}
}

// See if this is a reference to a file descriptor. These paths should
// reflect the shell's current execution fds, which can differ from the
// host process fds after redirections like here-docs.
Expand Down Expand Up @@ -242,3 +259,38 @@ fn shell_fd_path_to_fd(path: &Path) -> Option<ShellFd> {
None
}
}

/// Best-effort determination of whether an [`std::fs::OpenOptions`] requests
/// write access (including append). Used solely to inform the capability
/// interceptor's `before_open` hook of write-intent.
///
/// `std::fs::OpenOptions` exposes no getters for its configured flags, so we
/// inspect its `Debug` representation, which is documented to render the
/// `write` and `append` booleans. If the format ever changes such that we
/// cannot determine intent, we conservatively report `true` (write) so that a
/// confinement policy never under-reports access.
fn open_options_request_write(options: &std::fs::OpenOptions) -> bool {
let rendered = format!("{options:?}");

// Reads the boolean value of a `name: <bool>` field from the rendered
// debug string without slicing on byte offsets (which could panic on
// multi-byte boundaries).
let flag = |name: &str| -> Option<bool> {
let needle = format!("{name}: ");
let tail = rendered.split_once(&needle)?.1;
if tail.starts_with("true") {
Some(true)
} else if tail.starts_with("false") {
Some(false)
} else {
None
}
};

match (flag("write"), flag("append")) {
// We could read both flags: write access iff either is set.
(Some(write), Some(append)) => write || append,
// Could not parse the expected fields; fail safe toward "write".
_ => true,
}
}
Loading