Summary
brush-core is explicitly designed to be embedded in other Rust programs
(Shell<SE: ShellExtensions>, the builder, the examples/custom-builtin.rs
guide). A natural and increasingly common reason to embed a shell is to run
untrusted or semi-trusted scripts — for example, the action plans produced
by an LLM agent — while applying a host-defined policy about which programs may
run and which files may be touched.
Today an embedder cannot reliably enforce such a policy in-process. There is
no hook on the path from "the shell decided to run an external command / open a
file" to "the OS call happens." The only extension point on ShellExtensions is
ErrorFormatter, which is about presentation, not authority.
The concrete gap
External command dispatch in brush-core/src/commands.rs
(CommandExecutionInfo::execute) has two branches:
// brush-core/src/commands.rs (around the resolution logic)
if !sys::fs::contains_path_separator(&self.command_name) {
// PATH search + builtin table consulted here ...
if let Some(path) = path {
self.execute_via_external(&path)
} else {
Err(ErrorKind::CommandNotFound(self.command_name).into())
}
} else {
// Path-separator branch: PATH and the builtin table are BYPASSED.
let command_name = PathBuf::from(self.command_name.clone());
self.execute_via_external(command_name.as_path())
}
This is correct bash behavior — a command whose name contains a / (e.g.
/bin/rm, ./script, ../x) is run directly without a PATH search and without
considering builtins. But it means an embedder who tries to confine execution by
inspecting names/PATH cannot stop /bin/rm: it skips exactly the machinery they
might hook. Any name-based or PATH-based gate is trivially bypassed by spelling
the command with a path separator.
File opens have the same shape: redirections (> file, < file, >> file) and
source/. all funnel through Shell::open_file in
brush-core/src/shell/fs.rs, but there is no hook there either.
Proposal
Add a new optional component trait to ShellExtensions — call it
CommandInterceptor — with two hooks that default to "allow," so existing
embedders and the default shell are byte-for-byte unchanged:
pub enum ExecDecision { Allow, Deny(String) }
pub enum OpenDecision { Allow, Deny(String) }
pub trait CommandInterceptor: Clone + Default + Send + Sync + 'static {
fn before_exec(&self, program: &str, args: &[String]) -> ExecDecision {
ExecDecision::Allow
}
fn before_open(&self, path: &std::path::Path, write: bool) -> OpenDecision {
OpenDecision::Allow
}
}
before_exec is called at the single external-spawn funnel
(execute_external_command), so both dispatch branches — including the
path-separator branch — are covered. A policy here cannot be circumvented by
spelling the command differently.
before_open is called inside Shell::open_file, the single choke point all
filesystem-path opens flow through (redirections and source/.).
- On
Deny, the operation fails with a clear error (a new
ErrorKind::ExecDenied for execs; a PermissionDenied io::Error for opens,
which the existing redirection/source error wrapping already surfaces). It does
not panic and does not silently skip.
This mirrors the existing ErrorFormatter pattern exactly: a sub-trait collected
as an associated type on ShellExtensions, with a Default* implementation that
is the no-op/standard behavior.
Why upstream might want this
- It makes
brush-core usable as a sandboxable embedded shell — a
differentiator versus shelling out to bash.
- It is purely additive and zero-cost when unused (the default impl is inlined to
nothing).
- It does not commit brush to any particular sandboxing technology (Landlock,
namespaces, seccomp, brokered FDs); it is the minimal in-process seam those
approaches can build on.
Use case that motivated it (agent-bridle)
We are building an object-capability ("ocap") confinement layer for LLM coding
agents. The agent harness is a classic confused deputy: it holds the user's
full ambient authority and simultaneously executes untrusted instructions. The
ocap remedy is to hand each agent an attenuated capability — "you may run these
programs, you may write under this directory" — and to enforce attenuation at the
point of use. An embedded brush is the script-execution surface, so the
enforcement point must be inside the shell, before the spawn/open happens. The
path-separator bypass is precisely the kind of hole that makes name-based
allowlists worthless; before_exec at the spawn funnel closes it.
Summary
brush-coreis explicitly designed to be embedded in other Rust programs(
Shell<SE: ShellExtensions>, the builder, theexamples/custom-builtin.rsguide). A natural and increasingly common reason to embed a shell is to run
untrusted or semi-trusted scripts — for example, the action plans produced
by an LLM agent — while applying a host-defined policy about which programs may
run and which files may be touched.
Today an embedder cannot reliably enforce such a policy in-process. There is
no hook on the path from "the shell decided to run an external command / open a
file" to "the OS call happens." The only extension point on
ShellExtensionsisErrorFormatter, which is about presentation, not authority.The concrete gap
External command dispatch in
brush-core/src/commands.rs(
CommandExecutionInfo::execute) has two branches:This is correct bash behavior — a command whose name contains a
/(e.g./bin/rm,./script,../x) is run directly without a PATH search and withoutconsidering builtins. But it means an embedder who tries to confine execution by
inspecting names/PATH cannot stop
/bin/rm: it skips exactly the machinery theymight hook. Any name-based or PATH-based gate is trivially bypassed by spelling
the command with a path separator.
File opens have the same shape: redirections (
> file,< file,>> file) andsource/.all funnel throughShell::open_fileinbrush-core/src/shell/fs.rs, but there is no hook there either.Proposal
Add a new optional component trait to
ShellExtensions— call itCommandInterceptor— with two hooks that default to "allow," so existingembedders and the default shell are byte-for-byte unchanged:
before_execis called at the single external-spawn funnel(
execute_external_command), so both dispatch branches — including thepath-separator branch — are covered. A policy here cannot be circumvented by
spelling the command differently.
before_openis called insideShell::open_file, the single choke point allfilesystem-path opens flow through (redirections and
source/.).Deny, the operation fails with a clear error (a newErrorKind::ExecDeniedfor execs; aPermissionDeniedio::Errorfor opens,which the existing redirection/source error wrapping already surfaces). It does
not panic and does not silently skip.
This mirrors the existing
ErrorFormatterpattern exactly: a sub-trait collectedas an associated type on
ShellExtensions, with aDefault*implementation thatis the no-op/standard behavior.
Why upstream might want this
brush-coreusable as a sandboxable embedded shell — adifferentiator versus shelling out to
bash.nothing).
namespaces, seccomp, brokered FDs); it is the minimal in-process seam those
approaches can build on.
Use case that motivated it (agent-bridle)
We are building an object-capability ("ocap") confinement layer for LLM coding
agents. The agent harness is a classic confused deputy: it holds the user's
full ambient authority and simultaneously executes untrusted instructions. The
ocap remedy is to hand each agent an attenuated capability — "you may run these
programs, you may write under this directory" — and to enforce attenuation at the
point of use. An embedded
brushis the script-execution surface, so theenforcement point must be inside the shell, before the spawn/open happens. The
path-separator bypass is precisely the kind of hole that makes name-based
allowlists worthless;
before_execat the spawn funnel closes it.