Skip to content

Add an exec/open interception hook to ShellExtensions so embedders can confine commands in-process #1183

Description

@hartsock

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions